Compare commits
No commits in common. "master" and "main" have entirely different histories.
|
|
@ -1,4 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
|
||||
.npmrc
|
||||
|
|
|
|||
879
README.md
879
README.md
|
|
@ -1,879 +0,0 @@
|
|||
# @erp/plugin-sdk
|
||||
|
||||
SDK для створення плагінів ERP-платформи. Містить бекенд (NestJS) та фронтенд (React) інструменти для інтеграції з ядром.
|
||||
|
||||
## Зміст
|
||||
|
||||
- [Встановлення](#встановлення)
|
||||
- [Швидкий старт](#швидкий-старт)
|
||||
- [Архітектура](#архітектура)
|
||||
- [API Reference](#api-reference)
|
||||
- [CoreService](#coreservice)
|
||||
- [AttributeAccessService](#attributeaccessservice)
|
||||
- [CoreClientService](#coreclientservice)
|
||||
- [getRabbitMQOptions](#getrabbitmqoptions)
|
||||
- [getRabbitMQClientOptions](#getrabbitmqclientoptions)
|
||||
- [TransformInterceptor](#transforminterceptor)
|
||||
- [RequestContextInterceptor](#requestcontextinterceptor)
|
||||
- [AllExceptionsFilter](#allexceptionsfilter)
|
||||
- [Інтерфейси](#інтерфейси)
|
||||
- [Attribute Access Control (ABAC)](#attribute-access-control-abac)
|
||||
- [Події та RabbitMQ](#події-та-rabbitmq)
|
||||
- [Міжплагінна комунікація](#міжплагінна-комунікація)
|
||||
- [CLI — create-erp-plugin](#cli--create-erp-plugin)
|
||||
- [Експорт SDK](#експорт-sdk)
|
||||
|
||||
---
|
||||
|
||||
## Встановлення
|
||||
|
||||
```bash
|
||||
npm install @erp/plugin-sdk
|
||||
```
|
||||
|
||||
### `.npmrc`
|
||||
|
||||
SDK опубліковано в Gitea Packages. Додайте в `backend/.npmrc`:
|
||||
|
||||
```
|
||||
@erp:registry=https://gitea.ss.3w.com.ua/api/packages/erp/npm/
|
||||
```
|
||||
|
||||
### Dockerfile
|
||||
|
||||
```dockerfile
|
||||
COPY backend/.npmrc ./backend/
|
||||
RUN cd backend && npm install
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Швидкий старт
|
||||
|
||||
```bash
|
||||
npx create-erp-plugin my-plugin --port 3007
|
||||
```
|
||||
|
||||
Створює scaffold плагіна з:
|
||||
- `backend/` — NestJS бекенд з CRUD, RabbitMQ, TypeORM
|
||||
- `frontend/` — React (Module Federation) фронтенд
|
||||
- `manifest.json` — реєстрація плагіна в ядрі
|
||||
|
||||
---
|
||||
|
||||
## Архітектура
|
||||
|
||||
```
|
||||
┌───────────────────┐ HTTP/REST ┌──────────────┐
|
||||
│ Plugin A (BE) │ ◄──────────────────► │ Core (BE) │
|
||||
│ CoreService │ RabbitMQ │ Port 3001 │
|
||||
│ AttributeAccess │ ◄──────────────────► │ │
|
||||
└────────┬──────────┘ └──────────┬───┘
|
||||
│ ┌───────────────────────▲ │
|
||||
│ Plugin→Plugin│ HTTP RPC Proxy │ │
|
||||
│ v1.3.0 │ │ │
|
||||
│ ┌──────┴───────┐ │ │
|
||||
│ │ Plugin B (BE)│ │ │
|
||||
│ │ callPlugin │───────────────┘ │
|
||||
│ └──────────────┘ │
|
||||
│ │
|
||||
Module Federation Module Fed.
|
||||
│ │
|
||||
┌────────▼──────────┐ ┌──────────▼───┐
|
||||
│ Plugin A (FE) │ │ Plugin B(FE)│
|
||||
│ React + Vite │ │ React + Vite│
|
||||
│ host/UIKit │ │ host/UIKit │
|
||||
└───────────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
**Комунікація:**
|
||||
1. **HTTP (REST)** — `CoreService.fetchCore()` для запитів до ядра
|
||||
2. **RabbitMQ (асинхронно)** — `CoreService.emit()` для подій, `@EventPattern()` для отримання
|
||||
3. **Plugin→Plugin RPC** (v1.3.0+) — `CoreService.callPluginApi()` для виклику API іншого плагіна через core-proxy
|
||||
4. **Module Federation** — фронтенд плагіна монтується як remote module всередині host-застосунку
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### CoreService
|
||||
|
||||
Головний сервіс для комунікації плагіна з ядром.
|
||||
|
||||
```typescript
|
||||
import { CoreService } from '@erp/plugin-sdk';
|
||||
```
|
||||
|
||||
#### `fetchCore(endpoint, options?)`
|
||||
|
||||
HTTP-запит до ядра. Автоматично прокидyє JWT токен (з RequestContext).
|
||||
|
||||
```typescript
|
||||
const users = await coreService.fetchCore('/api/admin/users');
|
||||
const created = await coreService.fetchCore('/api/admin/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username: 'newuser', password: '123' }),
|
||||
});
|
||||
```
|
||||
|
||||
#### `register()`
|
||||
|
||||
Реєструє плагін в ядрі. Читає `manifest.json`, відправляє POST на `/api/plugins/register`. Повторює з ретраями поки не зареєструється.
|
||||
|
||||
```typescript
|
||||
await coreService.register();
|
||||
```
|
||||
|
||||
#### `emit<T>(pattern, payload)`
|
||||
|
||||
Асинхронна подія через RabbitMQ (fire-and-forget).
|
||||
|
||||
```typescript
|
||||
await coreService.emit('myplugin.data.synced', { recordId: 42 });
|
||||
```
|
||||
|
||||
#### `auditLog(action, resource, details?)`
|
||||
|
||||
Запис в аудит-лог ядра.
|
||||
|
||||
```typescript
|
||||
await coreService.auditLog('deals.create', 'deal:42', { dealName: 'Test' });
|
||||
```
|
||||
|
||||
#### `getGlobalData<T>(key)` / `setGlobalData(key, value)` / `deleteGlobalData(key)`
|
||||
|
||||
Глобальне сховище (key-value) в ядрі.
|
||||
|
||||
```typescript
|
||||
await coreService.setGlobalData('myplugin_config', { theme: 'dark' });
|
||||
const config = await coreService.getGlobalData('myplugin_config');
|
||||
```
|
||||
|
||||
#### `sendNotification(payload)`
|
||||
|
||||
Відправка сповіщення користувачу.
|
||||
|
||||
```typescript
|
||||
await coreService.sendNotification({
|
||||
userId: 1,
|
||||
title: 'Deal updated',
|
||||
message: 'Deal #42 has been modified',
|
||||
});
|
||||
```
|
||||
|
||||
#### `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()`
|
||||
|
||||
Отримати назву БД (після `setupDatabase`).
|
||||
|
||||
```typescript
|
||||
const dbName = coreService.getDbName();
|
||||
```
|
||||
|
||||
#### `static setupDatabase({ pluginId })`
|
||||
|
||||
Запитує у ядра виділену БД для плагіна. Викликати ДО ініціалізації NestJS.
|
||||
|
||||
```typescript
|
||||
// В main.ts перед створенням app
|
||||
const dbName = await CoreService.setupDatabase({ pluginId: 'my-plugin' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### AttributeAccessService
|
||||
|
||||
Сервіс для Attribute-Based Access Control (ABAC). Дозволяє керувати атрибутами користувачів і ресурсів та перевіряти доступ на основі правил.
|
||||
|
||||
```typescript
|
||||
import { AttributeAccessService } from '@erp/plugin-sdk';
|
||||
```
|
||||
|
||||
#### Типи
|
||||
|
||||
```typescript
|
||||
type AttributeMatch = 'eq' | 'neq' | 'in' | 'contains' | 'exists' | 'gte' | 'lte';
|
||||
|
||||
interface AttributeRule {
|
||||
userAttr: string; // ключ атрибута на користувачеві
|
||||
match: AttributeMatch; // тип порівняння
|
||||
resourceAttr?: string; // ключ атрибута на ресурсі
|
||||
value?: string; // або фіксоване значення
|
||||
}
|
||||
|
||||
interface ResourceRef {
|
||||
type: string; // наприклад 'deal', 'server'
|
||||
id: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### Робота з атрибутами користувачів
|
||||
|
||||
```typescript
|
||||
// Встановити атрибут
|
||||
await attrService.setUserAttribute(1, 'team', 'alpha', 'arbitration');
|
||||
|
||||
// Встановити кілька атрибутів
|
||||
await attrService.setUserAttributes(1, [
|
||||
{ key: 'team', value: 'alpha' },
|
||||
{ key: 'region', value: 'EU' },
|
||||
{ key: 'clearance', value: '3' },
|
||||
], 'arbitration');
|
||||
|
||||
// Отримати атрибути
|
||||
const attrs = await attrService.getUserAttributes(1);
|
||||
|
||||
// Видалити
|
||||
await attrService.deleteUserAttribute(1, 'team');
|
||||
```
|
||||
|
||||
#### Робота з атрибутами ресурсів
|
||||
|
||||
```typescript
|
||||
await attrService.setResourceAttribute('deal', '42', 'team', 'alpha', 'arbitration');
|
||||
await attrService.setResourceAttributes('deal', '42', [
|
||||
{ key: 'team', value: 'alpha' },
|
||||
{ key: 'region', value: 'EU' },
|
||||
], 'arbitration');
|
||||
|
||||
const attrs = await attrService.getResourceAttributes('deal', '42');
|
||||
await attrService.deleteResourceAttribute('deal', '42', 'team');
|
||||
```
|
||||
|
||||
#### Перевірка доступу
|
||||
|
||||
```typescript
|
||||
// 1. Check: чи user в тій самій команді що й deal?
|
||||
const allowed = await attrService.evaluate(1, [
|
||||
{ userAttr: 'team', match: 'eq', resourceAttr: 'team' }
|
||||
], { type: 'deal', id: '42' });
|
||||
|
||||
// 2. Check: чи має user рівень доступу >= 3?
|
||||
const allowed = await attrService.evaluate(1, [
|
||||
{ userAttr: 'clearance', match: 'gte', value: '3' }
|
||||
]);
|
||||
|
||||
// 3. Check: чи є user менеджером власника ресурсу?
|
||||
const allowed = await attrService.evaluate(1, [
|
||||
{ userAttr: 'subordinates', match: 'contains', resourceAttr: 'owner_id' }
|
||||
], { type: 'deal', id: '42' });
|
||||
```
|
||||
|
||||
#### Фільтрація списку
|
||||
|
||||
Корисно коли вже маєте список об'єктів і треба відфільтрувати доступні.
|
||||
|
||||
```typescript
|
||||
const visibleDeals = await attrService.filter(
|
||||
userId,
|
||||
[{ userAttr: 'team', match: 'eq', resourceAttr: 'team' }],
|
||||
allDeals,
|
||||
(deal) => ({ team: deal.team }) // екстрактор атрибутів з об'єкта
|
||||
);
|
||||
```
|
||||
|
||||
#### Типи порівняння
|
||||
|
||||
| `match` | Опис | Приклад |
|
||||
|---------|------|---------|
|
||||
| `eq` | Дорівнює | `user.team == resource.team` |
|
||||
| `neq` | Не дорівнює | `user.region != 'blocked'` |
|
||||
| `in` | Значення user в списку resource | `user.region in resource.allowed_regions` |
|
||||
| `contains` | Список user містить resource | `user.roles contains 'admin'` |
|
||||
| `exists` | Атрибут існує | `user.clearance exists` |
|
||||
| `gte` | >= | `user.clearance >= 3` |
|
||||
| `lte` | <= | `user.priority <= 2` |
|
||||
|
||||
---
|
||||
|
||||
### CoreClientService
|
||||
|
||||
Альтернативний спосіб комунікації з ядром через RabbitMQ RPC.
|
||||
|
||||
```typescript
|
||||
import { CoreClientService } from '@erp/plugin-sdk';
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Запитати дані користувача (синхронно через RabbitMQ RPC)
|
||||
const userInfo = await coreClient.getUserInfo(1);
|
||||
|
||||
// Сповістити ядро про подію
|
||||
await coreClient.notifyCore('myplugin.event', { data: 'value' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### getRabbitMQOptions
|
||||
|
||||
Повертає `MicroserviceOptions` для NestJS. Використовується в `main.ts` для підключення до RabbitMQ.
|
||||
|
||||
```typescript
|
||||
import { getRabbitMQOptions } from '@erp/plugin-sdk';
|
||||
|
||||
// Звичайне підключення — отримує всі події
|
||||
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)
|
||||
- Routing keys: `#` (всі події) + `plugin.<id>.*` (якщо передано `pluginId`)
|
||||
- Queue: вказана назва (durable)
|
||||
- Повідомлення: persistent
|
||||
|
||||
---
|
||||
|
||||
### getRabbitMQClientOptions
|
||||
|
||||
Повертає конфігурацію для `ClientsModule.register()`.
|
||||
|
||||
```typescript
|
||||
import { getRabbitMQClientOptions } from '@erp/plugin-sdk';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ClientsModule.register([
|
||||
{ name: 'RABBITMQ_CLIENT', ...getRabbitMQClientOptions() },
|
||||
]),
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### TransformInterceptor
|
||||
|
||||
NestJS interceptor — обгортає всі відповіді в `ApiResponse` формат.
|
||||
|
||||
```typescript
|
||||
import { TransformInterceptor } from '@erp/plugin-sdk';
|
||||
|
||||
app.useGlobalInterceptors(new TransformInterceptor());
|
||||
```
|
||||
|
||||
Формат відповіді:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { ... },
|
||||
"meta": {
|
||||
"timestamp": 1716800000000,
|
||||
"traceId": "trace-xxx",
|
||||
"service": "my-plugin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### RequestContextInterceptor
|
||||
|
||||
NestJS interceptor — зберігає `authorization` токен та IP в `AsyncLocalStorage`. Використовується `CoreService.fetchCore()` для автоматичного прокидвання JWT.
|
||||
|
||||
```typescript
|
||||
import { RequestContextInterceptor } from '@erp/plugin-sdk';
|
||||
|
||||
app.useGlobalInterceptors(new RequestContextInterceptor());
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### AllExceptionsFilter
|
||||
|
||||
NestJS exception filter — перехоплює всі необроблені помилки і повертає стандартизований `ApiResponse` з помилкою.
|
||||
|
||||
```typescript
|
||||
import { AllExceptionsFilter } from '@erp/plugin-sdk';
|
||||
|
||||
app.useGlobalFilters(new AllExceptionsFilter());
|
||||
```
|
||||
|
||||
Формат помилки:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ERR_404",
|
||||
"message": "Item not found",
|
||||
"details": null
|
||||
},
|
||||
"meta": {
|
||||
"timestamp": 1716800000000,
|
||||
"traceId": "uuid",
|
||||
"service": "my-plugin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Інтерфейси
|
||||
|
||||
#### `ApiResponse<T>`
|
||||
|
||||
Стандартна відповідь API.
|
||||
|
||||
```typescript
|
||||
interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: { code: string; message: string; details?: any };
|
||||
meta: { timestamp: number; traceId: string; service: string };
|
||||
}
|
||||
```
|
||||
|
||||
#### `SystemEvent<T>`
|
||||
|
||||
Конверт для асинхронних подій RabbitMQ.
|
||||
|
||||
```typescript
|
||||
interface SystemEvent<T = any> {
|
||||
eventId: string;
|
||||
pattern: string;
|
||||
payload: T;
|
||||
source: string;
|
||||
timestamp: number;
|
||||
priority?: 'low' | 'medium' | 'high';
|
||||
}
|
||||
```
|
||||
|
||||
#### `host/UIKit` (фронтенд)
|
||||
|
||||
Типи для компонентів інтерфейсу, що надаються host-застосунком:
|
||||
|
||||
```typescript
|
||||
import { Button, Input, Card, Typography } from 'host/UIKit';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Attribute Access Control (ABAC)
|
||||
|
||||
Система атрибутивного контролю доступу дозволяє плагінам визначати правила доступу на основі атрибутів користувачів і ресурсів, не прив'язуючи ядро до конкретних атрибутів.
|
||||
|
||||
### Як це працює
|
||||
|
||||
1. **Плагін визначає атрибути** — наприклад `team`, `region`, `clearance`
|
||||
2. **Ядро зберігає атрибути** — key-value для юзерів і ресурсів
|
||||
3. **Плагін задає правила** — наприклад `user.team == resource.team`
|
||||
4. **Ядро перевіряє** — порівнює атрибути згідно з правилами
|
||||
|
||||
### Приклад: арбітражний плагін
|
||||
|
||||
```typescript
|
||||
// 1. Адмін призначає атрибути
|
||||
await attrService.setUserAttributes(userId, [
|
||||
{ key: 'team', value: 'alpha' },
|
||||
{ key: 'region', value: 'EU' },
|
||||
], 'arbitration');
|
||||
|
||||
// 2. При створенні deal, зберігаємо атрибути ресурсу
|
||||
await attrService.setResourceAttributes('deal', dealId, [
|
||||
{ key: 'team', value: deal.team },
|
||||
{ key: 'region', value: deal.region },
|
||||
], 'arbitration');
|
||||
|
||||
// 3. При перегляді — перевіряємо
|
||||
const canView = await attrService.evaluate(userId, [
|
||||
{ userAttr: 'team', match: 'eq', resourceAttr: 'team' },
|
||||
{ userAttr: 'region', match: 'eq', resourceAttr: 'region' },
|
||||
], { type: 'deal', id: dealId });
|
||||
|
||||
if (!canView) throw new ForbiddenException();
|
||||
```
|
||||
|
||||
### Раунд-тріп vs локальна фільтрація
|
||||
|
||||
`evaluate()` робить HTTP-запит до ядра (перевіряє атрибути ресурсу в БД).
|
||||
`filter()` робить один запит для отримання атрибутів юзера, потім фільтрує локально.
|
||||
|
||||
### Інтеграція з RBAC
|
||||
|
||||
```typescript
|
||||
async viewDeal(userId: number, dealId: string) {
|
||||
// 1. RBAC — чи має дозвіл
|
||||
const hasPerm = await this.usersService.hasPermission(userId, 'deals.view');
|
||||
if (!hasPerm) throw new ForbiddenException();
|
||||
|
||||
// 2. ABAC — чи збігаються атрибути
|
||||
const access = await this.attributeService.evaluate(userId, [
|
||||
{ userAttr: 'team', match: 'eq', resourceAttr: 'team' }
|
||||
], { type: 'deal', id: dealId });
|
||||
if (!access) throw new ForbiddenException();
|
||||
|
||||
return this.dealRepo.findOne(dealId);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Події та RabbitMQ
|
||||
|
||||
### Відправка подій (emit)
|
||||
|
||||
```typescript
|
||||
// З плагіна в ядро (або інші плагіни)
|
||||
await coreService.emit('myplugin.data.updated', { id: 42 });
|
||||
|
||||
// Конверт автоматично обгортається в SystemEvent:
|
||||
// { eventId, pattern: 'myplugin.data.updated', payload: { id: 42 }, source: 'my-plugin', timestamp }
|
||||
```
|
||||
|
||||
### Отримання подій (@EventPattern)
|
||||
|
||||
```typescript
|
||||
import { EventPattern, Payload } from '@nestjs/microservices';
|
||||
|
||||
@Injectable()
|
||||
export class MyHandler {
|
||||
@EventPattern('core.search.request')
|
||||
async handle(@Payload() data: any) {
|
||||
const payload = data.payload || data;
|
||||
// обробка...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Підключення мікросервісу в `main.ts`:
|
||||
|
||||
```typescript
|
||||
import { getRabbitMQOptions } from '@erp/plugin-sdk';
|
||||
|
||||
app.connectMicroservice(getRabbitMQOptions('plugin_my_queue'));
|
||||
await app.startAllMicroservices();
|
||||
```
|
||||
|
||||
### Шаблон "Запит-Відповідь" (Request/Response)
|
||||
|
||||
Для двосторонньої комунікації через події:
|
||||
|
||||
```typescript
|
||||
// Core відправляє запит
|
||||
coreService.emit('plugin.some.request', { requestId, ... });
|
||||
|
||||
// Плагін відповідає
|
||||
@EventPattern('plugin.some.request')
|
||||
async handle(@Payload() data: any) {
|
||||
const { requestId } = data.payload || data;
|
||||
this.client.emit('core.some.response', { requestId, result: 'ok' });
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Міжплагінна комунікація
|
||||
|
||||
До версії 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
|
||||
|
||||
Генерація нового плагіна:
|
||||
|
||||
```bash
|
||||
npx create-erp-plugin <plugin-name> [--port <port>]
|
||||
```
|
||||
|
||||
### Параметри
|
||||
|
||||
| Аргумент | Опис |
|
||||
|----------|------|
|
||||
| `plugin-name` | Назва в kebab-case (наприклад `my-plugin`) |
|
||||
| `--port`, `-p` | Порт (1024–65535, за замовчуванням 3002) |
|
||||
|
||||
### Режими БД
|
||||
|
||||
У `manifest.json`:
|
||||
|
||||
| `database` | Опис |
|
||||
|------------|------|
|
||||
| `"self"` (default) | Плагін використовує спільну БД через `POSTGRES_*` змінні |
|
||||
| `"provide"` | Ядро створює виділену БД `p_<pluginId>` |
|
||||
|
||||
### Структура згенерованого плагіна
|
||||
|
||||
```
|
||||
my-plugin/
|
||||
├── manifest.json # Реєстраційний маніфест
|
||||
├── backend/
|
||||
│ ├── .npmrc # @erp:registry=...
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ ├── nest-cli.json
|
||||
│ ├── Dockerfile
|
||||
│ └── src/
|
||||
│ ├── main.ts
|
||||
│ ├── app.module.ts
|
||||
│ ├── app.controller.ts
|
||||
│ ├── ping.controller.ts
|
||||
│ ├── item.controller.ts
|
||||
│ ├── item.service.ts
|
||||
│ └── entities/
|
||||
│ └── item.entity.ts
|
||||
├── frontend/
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ ├── vite.config.ts
|
||||
│ ├── index.html
|
||||
│ └── src/
|
||||
│ └── App.tsx
|
||||
└── k8s-plugin.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Експорт SDK
|
||||
|
||||
```
|
||||
@erp/plugin-sdk
|
||||
├── CoreService # Головний сервіс (fetchCore, emit, register, callPluginApi, discoverPlugins, findPluginsByEvent, emitToPlugin, requestPlugin, ...)
|
||||
├── CoreClientService # RabbitMQ RPC клієнт
|
||||
├── AttributeAccessService # ABAC — керування атрибутами та перевірка доступу
|
||||
├── getRabbitMQOptions() # NestJS Microservice options
|
||||
├── getRabbitMQClientOptions() # NestJS Client options
|
||||
├── TransformInterceptor # Обгортка відповідей в ApiResponse
|
||||
├── RequestContextInterceptor # AsyncLocalStorage для JWT
|
||||
├── AllExceptionsFilter # Глобальний фільтр помилок
|
||||
├── ApiResponse # Інтерфейс відповіді
|
||||
├── SystemEvent # Інтерфейс події
|
||||
└── host/UIKit (types) # Типи UI компонентів
|
||||
```
|
||||
|
|
@ -1,45 +1,33 @@
|
|||
{
|
||||
"name": "@erp/plugin-sdk",
|
||||
"version": "1.11.0",
|
||||
"name": "erp-plugin-sdk",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@erp/plugin-sdk",
|
||||
"version": "1.11.0",
|
||||
"name": "erp-plugin-sdk",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"create-erp-plugin": "dist/cli/index.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/common": "^11.1.23",
|
||||
"@nestjs/core": "^11.1.23",
|
||||
"@nestjs/microservices": "^11.1.23",
|
||||
"@types/amqplib": "^0.10.8",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^6.0.3",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@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"
|
||||
"bin": {
|
||||
"create-erp-plugin": "dist/cli/index.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@borewit/text-codec": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz",
|
||||
"integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
|
@ -50,7 +38,6 @@
|
|||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz",
|
||||
"integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -60,7 +47,6 @@
|
|||
"version": "11.1.23",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.23.tgz",
|
||||
"integrity": "sha512-qKgEqwQXHIVu8TwiISmgbTrGHAFBsseP86KNolBZwAiHQryinJ5FPiDpp0ZJBBryY+WEMnsqaCa4TSxVuQEWug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"file-type": "21.3.4",
|
||||
|
|
@ -92,7 +78,6 @@
|
|||
"version": "11.1.23",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.23.tgz",
|
||||
"integrity": "sha512-Yd+mVFUilw4A6PzV7tyfiW+zrG2wmRXnFZVmNQA+fl1N0k6km4bhhNboxjLu//dzl+XiZI5AsOHHOTegzvOgNQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -134,7 +119,6 @@
|
|||
"version": "11.1.23",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/microservices/-/microservices-11.1.23.tgz",
|
||||
"integrity": "sha512-iVPp3u274Xx6XAEbKPWDCzUHP6wlt7L7RXUIyIpf3caJ3ldFh/dG3uyHva1lgjHG+ukz+XCXafl1cx352TPW0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iterare": "1.2.1",
|
||||
|
|
@ -193,7 +177,6 @@
|
|||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz",
|
||||
"integrity": "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"consola": "^3.2.3"
|
||||
|
|
@ -210,7 +193,6 @@
|
|||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
|
||||
"integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
|
|
@ -228,24 +210,12 @@
|
|||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
|
|
@ -255,7 +225,6 @@
|
|||
"version": "19.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
|
||||
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
|
|
@ -265,35 +234,12 @@
|
|||
"version": "9.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
|
||||
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==",
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
|
|
@ -303,14 +249,12 @@
|
|||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
|
|
@ -328,14 +272,12 @@
|
|||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-type": {
|
||||
"version": "21.3.4",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz",
|
||||
"integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/inflate": "^0.4.1",
|
||||
|
|
@ -354,7 +296,6 @@
|
|||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -375,7 +316,6 @@
|
|||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz",
|
||||
"integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
|
|
@ -385,7 +325,6 @@
|
|||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz",
|
||||
"integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -405,46 +344,28 @@
|
|||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"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": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
|
|
@ -454,7 +375,6 @@
|
|||
"version": "10.3.5",
|
||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz",
|
||||
"integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/token": "^0.3.0"
|
||||
|
|
@ -471,7 +391,6 @@
|
|||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
|
||||
"integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@borewit/text-codec": "^0.2.1",
|
||||
|
|
@ -490,14 +409,12 @@
|
|||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
|
@ -511,7 +428,6 @@
|
|||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz",
|
||||
"integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lukeed/csprng": "^1.0.0"
|
||||
|
|
@ -524,7 +440,6 @@
|
|||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
|
||||
"integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -537,20 +452,8 @@
|
|||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
|
||||
|
|
|
|||
28
package.json
28
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@erp/plugin-sdk",
|
||||
"version": "1.11.3",
|
||||
"name": "erp-plugin-sdk",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"bin": {
|
||||
|
|
@ -18,35 +18,17 @@
|
|||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "ERP Plugin SDK — build micro-frontend/backend plugins for the ERP platform",
|
||||
"files": [
|
||||
"dist/",
|
||||
"README.md"
|
||||
],
|
||||
"publishConfig": {
|
||||
"registry": "https://gitea.ss.3w.com.ua/api/packages/erp/npm/"
|
||||
},
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/common": "^11.1.23",
|
||||
"@nestjs/core": "^11.1.23",
|
||||
"@nestjs/microservices": "^11.1.23",
|
||||
"@types/amqplib": "^0.10.8",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^6.0.3",
|
||||
"uuid": "^9.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import { Transport, MicroserviceOptions, RmqOptions } from '@nestjs/microservices';
|
||||
import * as amqp from 'amqplib';
|
||||
import { Transport, MicroserviceOptions } from '@nestjs/microservices';
|
||||
|
||||
export const getRabbitMQOptions = (queueName: string, url?: string, pluginId?: string): MicroserviceOptions => {
|
||||
const routingKeys: string[] = ['#'];
|
||||
if (pluginId) {
|
||||
routingKeys.push(`plugin.${pluginId}.*`);
|
||||
}
|
||||
export const getRabbitMQOptions = (queueName: string, url?: string): MicroserviceOptions => {
|
||||
return {
|
||||
transport: Transport.RMQ,
|
||||
options: {
|
||||
|
|
@ -13,13 +8,11 @@ export const getRabbitMQOptions = (queueName: string, url?: string, pluginId?: s
|
|||
queue: queueName,
|
||||
exchange: 'system_events',
|
||||
exchangeType: 'topic',
|
||||
routingKey: routingKeys,
|
||||
queueOptions: {
|
||||
durable: true,
|
||||
durable: true, // Черга не зникає при перезавантаженні RabbitMQ
|
||||
},
|
||||
persistent: true, // Повідомлення зберігаються на диску
|
||||
},
|
||||
persistent: true,
|
||||
noAck: true,
|
||||
} as RmqOptions['options'] & { routingKey: string[] },
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -37,29 +30,3 @@ 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(() => {});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export { HelpModule } from './help.module';
|
||||
export { HelpService, HelpArticle } from './help.service';
|
||||
export { HelpController } from './help.controller';
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { CoreService } from './core.service';
|
||||
|
||||
export type AttributeMatch = 'eq' | 'neq' | 'in' | 'contains' | 'exists' | 'gte' | 'lte';
|
||||
|
||||
export interface AttributeRule {
|
||||
userAttr: string;
|
||||
match: AttributeMatch;
|
||||
resourceAttr?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface ResourceRef {
|
||||
type: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AttributeAccessService {
|
||||
private readonly logger = new Logger(AttributeAccessService.name);
|
||||
|
||||
constructor(private readonly core: CoreService) {}
|
||||
|
||||
// ── User Attributes ──
|
||||
|
||||
async setUserAttribute(userId: number, key: string, value: string, pluginId?: string): Promise<void> {
|
||||
await this.core.fetchCore(`/api/attributes/users/${userId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key, value, pluginId }),
|
||||
});
|
||||
}
|
||||
|
||||
async setUserAttributes(userId: number, attributes: Array<{ key: string; value: string }>, pluginId?: string): Promise<void> {
|
||||
await this.core.fetchCore('/api/attributes/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, attributes, pluginId }),
|
||||
});
|
||||
}
|
||||
|
||||
async getUserAttributes(userId: number): Promise<Array<{ key: string; value: string; pluginId?: string }>> {
|
||||
const res = await this.core.fetchCore(`/api/attributes/users/${userId}`);
|
||||
return res.data || res || [];
|
||||
}
|
||||
|
||||
async deleteUserAttribute(userId: number, key: string): Promise<void> {
|
||||
await this.core.fetchCore(`/api/attributes/users/${userId}?key=${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// ── Resource Attributes ──
|
||||
|
||||
async setResourceAttribute(resourceType: string, resourceId: string, key: string, value: string, pluginId?: string): Promise<void> {
|
||||
await this.core.fetchCore(`/api/attributes/resources/${resourceType}/${resourceId}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key, value, pluginId }),
|
||||
});
|
||||
}
|
||||
|
||||
async setResourceAttributes(resourceType: string, resourceId: string, attributes: Array<{ key: string; value: string }>, pluginId?: string): Promise<void> {
|
||||
await this.core.fetchCore('/api/attributes/resources/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ resourceType, resourceId, attributes, pluginId }),
|
||||
});
|
||||
}
|
||||
|
||||
async getResourceAttributes(resourceType: string, resourceId: string): Promise<Array<{ key: string; value: string; pluginId?: string }>> {
|
||||
const res = await this.core.fetchCore(`/api/attributes/resources/${resourceType}/${resourceId}`);
|
||||
return res.data || res || [];
|
||||
}
|
||||
|
||||
async deleteResourceAttribute(resourceType: string, resourceId: string, key: string): Promise<void> {
|
||||
await this.core.fetchCore(`/api/attributes/resources/${resourceType}/${resourceId}?key=${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// ── Evaluation ──
|
||||
|
||||
async evaluate(
|
||||
userId: number,
|
||||
rules: AttributeRule[],
|
||||
resource?: ResourceRef,
|
||||
): Promise<boolean> {
|
||||
const res = await this.core.fetchCore('/api/attributes/evaluate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId, rules, resource }),
|
||||
});
|
||||
const data = res.data || res;
|
||||
return data.allowed === true;
|
||||
}
|
||||
|
||||
// ── Convenience ──
|
||||
|
||||
async filter<T>(
|
||||
userId: number,
|
||||
rules: AttributeRule[],
|
||||
items: T[],
|
||||
extractAttrs: (item: T) => Record<string, string>,
|
||||
): Promise<T[]> {
|
||||
const userAttrs = await this.getUserAttributes(userId);
|
||||
const userAttrMap = new Map(userAttrs.map(a => [a.key, a.value]));
|
||||
|
||||
return items.filter(item => {
|
||||
const itemAttrs = extractAttrs(item);
|
||||
|
||||
for (const rule of rules) {
|
||||
const userValue = userAttrMap.get(rule.userAttr);
|
||||
|
||||
if (rule.match === 'exists') {
|
||||
if (!userValue) return false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!userValue) return false;
|
||||
|
||||
if (rule.resourceAttr) {
|
||||
const resourceValue = itemAttrs[rule.resourceAttr];
|
||||
if (resourceValue === undefined) return false;
|
||||
if (!this.compareValues(userValue, resourceValue, rule.match)) return false;
|
||||
} else if (rule.value !== undefined) {
|
||||
if (!this.compareWithValue(userValue, rule.value, rule.match)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private compareValues(userValue: string, resourceValue: string, match: AttributeMatch): boolean {
|
||||
switch (match) {
|
||||
case 'eq': return userValue === resourceValue;
|
||||
case 'neq': return userValue !== resourceValue;
|
||||
case 'in': return resourceValue.split(',').map(s => s.trim()).includes(userValue);
|
||||
case 'contains': return userValue.split(',').map(s => s.trim()).includes(resourceValue);
|
||||
case 'exists': return true;
|
||||
case 'gte': return Number(userValue) >= Number(resourceValue);
|
||||
case 'lte': return Number(userValue) <= Number(resourceValue);
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
private compareWithValue(userValue: string, matchValue: string, match: AttributeMatch): boolean {
|
||||
switch (match) {
|
||||
case 'eq': return userValue === matchValue;
|
||||
case 'neq': return userValue !== matchValue;
|
||||
case 'gte': return Number(userValue) >= Number(matchValue);
|
||||
case 'lte': return Number(userValue) <= Number(matchValue);
|
||||
case 'exists': return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,10 @@
|
|||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||
import { ClientProxy } from '@nestjs/microservices';
|
||||
import { firstValueFrom, timeout as rxTimeout } from 'rxjs';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { SystemEvent } from '../interfaces/communication';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as amqp from 'amqplib';
|
||||
import { requestContext } from '../interceptors/request-context.interceptor';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -19,10 +17,14 @@ 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();
|
||||
const authHeader = store?.get('authorization');
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(authHeader ? { 'Authorization': authHeader } : {}),
|
||||
|
|
@ -43,6 +45,8 @@ 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 = [
|
||||
|
|
@ -60,13 +64,12 @@ 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 {
|
||||
|
|
@ -92,38 +95,18 @@ export class CoreService {
|
|||
|
||||
const response = await fetch(`${coreUrl}/api/plugins/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(registrationKey ? { 'x-registration-key': registrationKey } : {}),
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -158,169 +141,7 @@ export class CoreService {
|
|||
} catch (error: any) {
|
||||
this.logger.error(`[Heartbeat] Error sending heartbeat:`, error.message || error);
|
||||
}
|
||||
}, 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';
|
||||
}, 30 * 1000); // 30 seconds
|
||||
}
|
||||
|
||||
async emit<T>(pattern: string, payload: T): Promise<void> {
|
||||
|
|
@ -331,19 +152,9 @@ export class CoreService {
|
|||
source: this.serviceName,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
try {
|
||||
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}`);
|
||||
}
|
||||
this.client.emit(pattern, event).subscribe({
|
||||
error: (err) => this.logger.error(`Failed to emit event ${pattern}:`, err),
|
||||
});
|
||||
}
|
||||
|
||||
async auditLog(action: string, resource: string, details?: any): Promise<void> {
|
||||
|
|
@ -416,56 +227,44 @@ export class CoreService {
|
|||
}
|
||||
|
||||
async sendNotification(payload: { userId: number | null; title: string; message: string; icon?: string; image?: string; details?: any }): Promise<void> {
|
||||
try {
|
||||
const conn = await amqp.connect(this.getRMQUrl());
|
||||
const ch = await conn.createChannel();
|
||||
await ch.assertExchange('system_events', 'topic', { durable: true });
|
||||
ch.publish('system_events', 'system.notification.send', Buffer.from(JSON.stringify({
|
||||
pattern: 'system.notification.send',
|
||||
data: {
|
||||
await this.emit('system.notification.send', {
|
||||
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 {
|
||||
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,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pluginId: options.pluginId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Database setup failed: ${response.status} ${response.statusText}`);
|
||||
throw new Error(`Database setup failed: ${response.statusText}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
const data = result.data || result;
|
||||
const dbName = data.dbName;
|
||||
if (!dbName) throw new Error('Database name not returned from core');
|
||||
process.env.PLUGIN_DB_NAME = dbName;
|
||||
if (data.host && !process.env.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.host) process.env.PLUGIN_DB_HOST = data.host;
|
||||
if (data.port) process.env.PLUGIN_DB_PORT = String(data.port);
|
||||
if (data.username) process.env.PLUGIN_DB_USER = data.username;
|
||||
if (data.password) process.env.PLUGIN_DB_PASSWORD = data.password;
|
||||
return dbName;
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
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;
|
||||
}
|
||||
197
src/cli/index.ts
197
src/cli/index.ts
|
|
@ -43,7 +43,6 @@ function buildTemplates(name: string, port: number): FileTemplate[] {
|
|||
remoteEntry: `http://localhost:${port}/remoteEntry.js`,
|
||||
exposedModule: './App',
|
||||
roles: ['admin'],
|
||||
database: 'self',
|
||||
}, null, 2) + '\n',
|
||||
},
|
||||
{
|
||||
|
|
@ -106,15 +105,12 @@ spec:
|
|||
'@nestjs/core': '^10.0.0',
|
||||
'@nestjs/microservices': '^10.0.0',
|
||||
'@nestjs/platform-express': '^10.0.0',
|
||||
'@nestjs/typeorm': '^10.0.2',
|
||||
'@nestjs/serve-static': '^4.0.2',
|
||||
'amqp-connection-manager': '^4.1.14',
|
||||
amqplib: '^0.10.3',
|
||||
'@erp/plugin-sdk': '^1.2.0',
|
||||
pg: '^8.12.0',
|
||||
'erp-plugin-sdk': 'git+https://gitea.ss.3w.com.ua/erp/erp-plugin-sdk.git',
|
||||
'reflect-metadata': '^0.1.13',
|
||||
rxjs: '^7.8.1',
|
||||
typeorm: '^0.3.20',
|
||||
uuid: '^9.0.1',
|
||||
},
|
||||
devDependencies: {
|
||||
|
|
@ -125,10 +121,6 @@ spec:
|
|||
},
|
||||
}, null, 2) + '\n',
|
||||
},
|
||||
{
|
||||
relativePath: 'backend/.npmrc',
|
||||
content: '@erp:registry=https://gitea.ss.3w.com.ua/api/packages/erp/npm/\n',
|
||||
},
|
||||
{
|
||||
relativePath: 'backend/tsconfig.json',
|
||||
content: JSON.stringify({
|
||||
|
|
@ -170,7 +162,6 @@ spec:
|
|||
WORKDIR /app
|
||||
|
||||
COPY backend/package*.json ./backend/
|
||||
COPY backend/.npmrc ./backend/
|
||||
COPY frontend/package*.json ./frontend/
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
|
@ -190,36 +181,14 @@ CMD ["npm", "run", "start:prod"]
|
|||
},
|
||||
{
|
||||
relativePath: 'backend/src/main.ts',
|
||||
content: `import { CoreService, RequestContextInterceptor } from '@erp/plugin-sdk';
|
||||
content: `import { getRabbitMQOptions, CoreService, RequestContextInterceptor } from 'erp-plugin-sdk';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('PluginBootstrap');
|
||||
|
||||
// Read manifest to determine database mode
|
||||
const manifestPath = path.join(process.cwd(), '..', 'manifest.json');
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
||||
if (manifest.database === 'provide') {
|
||||
logger.log('Core-provisioned database mode detected. Setting up...');
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
await CoreService.setupDatabase({ pluginId: '${name}' });
|
||||
logger.log('Database provisioned successfully');
|
||||
break;
|
||||
} catch (err: any) {
|
||||
logger.warn(\`Database setup attempt \${i + 1} failed: \${err.message}\`);
|
||||
if (i < 9) await new Promise(r => setTimeout(r, 3000));
|
||||
else throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { AppModule } = await import('./app.module');
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.useGlobalInterceptors(new RequestContextInterceptor());
|
||||
|
|
@ -245,7 +214,6 @@ async function bootstrap() {
|
|||
next();
|
||||
});
|
||||
|
||||
const { getRabbitMQOptions } = await import('@erp/plugin-sdk');
|
||||
app.connectMicroservice(getRabbitMQOptions('plugin_${snake}_queue'));
|
||||
|
||||
app.enableCors();
|
||||
|
|
@ -259,38 +227,19 @@ async function bootstrap() {
|
|||
|
||||
logger.log(\`Plugin Service is running on: http://localhost:\${port}\`);
|
||||
}
|
||||
|
||||
bootstrap().catch((err) => {
|
||||
console.error('Failed to bootstrap plugin:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
bootstrap();
|
||||
`,
|
||||
},
|
||||
{
|
||||
relativePath: 'backend/src/app.module.ts',
|
||||
content: `import { CoreService, getRabbitMQClientOptions } from '@erp/plugin-sdk';
|
||||
content: `import { CoreService, getRabbitMQClientOptions } from 'erp-plugin-sdk';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ClientsModule } from '@nestjs/microservices';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppController } from './app.controller';
|
||||
import { PingController } from './ping.controller';
|
||||
import { ItemController } from './item.controller';
|
||||
import { ItemService } from './item.service';
|
||||
import { Item } from './entities/item.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRoot({
|
||||
type: 'postgres',
|
||||
host: process.env.PLUGIN_DB_HOST || process.env.POSTGRES_HOST || 'localhost',
|
||||
port: parseInt(process.env.PLUGIN_DB_PORT || process.env.POSTGRES_PORT || '5432', 10),
|
||||
username: process.env.PLUGIN_DB_USER || process.env.POSTGRES_USER || 'postgres',
|
||||
password: process.env.PLUGIN_DB_PASSWORD || process.env.POSTGRES_PASSWORD || 'postgres',
|
||||
database: process.env.PLUGIN_DB_NAME || process.env.POSTGRES_DB || 'crm',
|
||||
autoLoadEntities: true,
|
||||
synchronize: true,
|
||||
}),
|
||||
TypeOrmModule.forFeature([Item]),
|
||||
ClientsModule.register([
|
||||
{
|
||||
name: 'RABBITMQ_CLIENT',
|
||||
|
|
@ -298,8 +247,8 @@ import { Item } from './entities/item.entity';
|
|||
},
|
||||
]),
|
||||
],
|
||||
controllers: [AppController, PingController, ItemController],
|
||||
providers: [CoreService, ItemService],
|
||||
controllers: [AppController, PingController],
|
||||
providers: [CoreService],
|
||||
exports: [CoreService],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -341,107 +290,6 @@ export class PingController {
|
|||
return { status: 'ok', timestamp: Date.now() };
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
relativePath: 'backend/src/item.controller.ts',
|
||||
content: `import { Controller, Get, Post, Put, Delete, Body, Param } from '@nestjs/common';
|
||||
import { ItemService } from './item.service';
|
||||
import { Item } from './entities/item.entity';
|
||||
|
||||
@Controller('items')
|
||||
export class ItemController {
|
||||
constructor(private readonly service: ItemService) {}
|
||||
|
||||
@Get()
|
||||
async findAll(): Promise<Item[]> {
|
||||
return this.service.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findOne(@Param('id') id: string): Promise<Item> {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() data: Partial<Item>): Promise<Item> {
|
||||
return this.service.create(data);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(@Param('id') id: string, @Body() data: Partial<Item>): Promise<Item> {
|
||||
return this.service.update(id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id') id: string): Promise<void> {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
relativePath: 'backend/src/item.service.ts',
|
||||
content: `import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Item } from './entities/item.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ItemService {
|
||||
constructor(
|
||||
@InjectRepository(Item)
|
||||
private readonly repo: Repository<Item>,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<Item[]> {
|
||||
return this.repo.find();
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<Item> {
|
||||
const item = await this.repo.findOne({ where: { id } });
|
||||
if (!item) throw new NotFoundException(\`Item \${id} not found\`);
|
||||
return item;
|
||||
}
|
||||
|
||||
async create(data: Partial<Item>): Promise<Item> {
|
||||
const item = this.repo.create(data);
|
||||
return this.repo.save(item);
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<Item>): Promise<Item> {
|
||||
await this.repo.update(id, data);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const result = await this.repo.delete(id);
|
||||
if (!result.affected) throw new NotFoundException(\`Item \${id} not found\`);
|
||||
}
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
relativePath: 'backend/src/entities/item.entity.ts',
|
||||
content: `import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity()
|
||||
export class Item {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
|
|
@ -500,10 +348,13 @@ export class Item {
|
|||
{
|
||||
relativePath: 'frontend/tsconfig.node.json',
|
||||
content: JSON.stringify({
|
||||
compilerOptions: {
|
||||
composite: true,
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'Node',
|
||||
allowSyntheticDefaultImports: true,
|
||||
},
|
||||
include: ['vite.config.ts'],
|
||||
}, null, 2) + '\n',
|
||||
},
|
||||
{
|
||||
|
|
@ -565,18 +416,16 @@ export default defineConfig({
|
|||
},
|
||||
{
|
||||
relativePath: 'frontend/src/App.tsx',
|
||||
content: `import React, { useState } from 'react';
|
||||
import { Puzzle, Calendar } from 'lucide-react';
|
||||
content: `import React from 'react';
|
||||
import { Puzzle } from 'lucide-react';
|
||||
// @ts-ignore
|
||||
import { Card, Typography, DatePicker } from 'host/UIKit';
|
||||
import { Card, Typography } from 'host/UIKit';
|
||||
|
||||
interface AppProps {
|
||||
language?: string;
|
||||
}
|
||||
|
||||
const App: React.FC<AppProps> = ({ language = 'en' }) => {
|
||||
const [dateValue, setDateValue] = useState<any>(undefined);
|
||||
|
||||
return (
|
||||
<Card className="plugin-card" title={language === 'uk' ? '${label}' : '${label}'}>
|
||||
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||
|
|
@ -587,16 +436,6 @@ const App: React.FC<AppProps> = ({ language = 'en' }) => {
|
|||
? 'Плагін успішно створено та підключено до CRM.'
|
||||
: 'Plugin successfully created and connected to CRM.'}
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
|
|
@ -620,10 +459,6 @@ Arguments:
|
|||
plugin-name Plugin name in kebab-case (e.g., "my-custom-plugin")
|
||||
--port, -p Port number for the plugin server (default: 3002)
|
||||
|
||||
Database modes (set in manifest.json):
|
||||
"self" (default) Plugin connects using shared POSTGRES_* env vars
|
||||
"provide" Core provisions a dedicated database; change manifest
|
||||
|
||||
Example:
|
||||
create-erp-plugin my-plugin --port 3007
|
||||
`);
|
||||
|
|
@ -659,16 +494,12 @@ Example:
|
|||
fs.writeFileSync(filePath, tpl.content, 'utf-8');
|
||||
}
|
||||
|
||||
console.log(`\u2705 Plugin "${name}" created successfully in ./${name}/`);
|
||||
console.log(`✅ Plugin "${name}" created successfully in ./${name}/`);
|
||||
console.log();
|
||||
console.log('Next steps:');
|
||||
console.log(` cd ${name}/backend && npm install`);
|
||||
console.log(` cd ${name}/frontend && npm install`);
|
||||
console.log(` cd ${name}/backend && npm run start:dev`);
|
||||
console.log();
|
||||
console.log('Database:');
|
||||
console.log(' Current mode: "self" (shared DB via POSTGRES_* env vars)');
|
||||
console.log(' To switch to a dedicated DB, set manifest.json "database": "provide"');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
|
|
|||
|
|
@ -23,61 +23,9 @@ declare module 'host/UIKit' {
|
|||
export const Card: React.FC<CardProps>;
|
||||
|
||||
export const Typography: {
|
||||
H1: React.FC<{ children: React.ReactNode; className?: string }>;
|
||||
H2: React.FC<{ children: React.ReactNode; className?: string }>;
|
||||
H3: React.FC<{ children: React.ReactNode; className?: string }>;
|
||||
P: React.FC<{ children: React.ReactNode; className?: string }>;
|
||||
Small: React.FC<{ children: React.ReactNode; className?: string; style?: React.CSSProperties }>;
|
||||
H1: React.FC<React.HTMLAttributes<HTMLHeadingElement>>;
|
||||
H2: React.FC<React.HTMLAttributes<HTMLHeadingElement>>;
|
||||
H3: React.FC<React.HTMLAttributes<HTMLHeadingElement>>;
|
||||
P: React.FC<React.HTMLAttributes<HTMLParagraphElement>>;
|
||||
};
|
||||
|
||||
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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@
|
|||
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';
|
||||
export * from './backend/config/rabbitmq.config';
|
||||
export * from './backend/services/attribute-access.service';
|
||||
export { HelpModule, HelpService } from './backend/help';
|
||||
|
|
|
|||
Loading…
Reference in New Issue