diff --git a/.gitignore b/.gitignore index 08dc7b0..d570088 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ node_modules/ -dist/ diff --git a/dist/backend/config/rabbitmq.config.d.ts b/dist/backend/config/rabbitmq.config.d.ts new file mode 100644 index 0000000..32caaf8 --- /dev/null +++ b/dist/backend/config/rabbitmq.config.d.ts @@ -0,0 +1,14 @@ +import { Transport, MicroserviceOptions } from '@nestjs/microservices'; +export declare const getRabbitMQOptions: (queueName: string, url?: string) => MicroserviceOptions; +export declare const getRabbitMQClientOptions: (url?: string) => { + transport: Transport.RMQ; + options: { + urls: string[]; + queue: string; + exchange: string; + exchangeType: string; + queueOptions: { + durable: boolean; + }; + }; +}; diff --git a/dist/backend/config/rabbitmq.config.js b/dist/backend/config/rabbitmq.config.js new file mode 100644 index 0000000..1328b5a --- /dev/null +++ b/dist/backend/config/rabbitmq.config.js @@ -0,0 +1,35 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getRabbitMQClientOptions = exports.getRabbitMQOptions = void 0; +const microservices_1 = require("@nestjs/microservices"); +const getRabbitMQOptions = (queueName, url) => { + return { + transport: microservices_1.Transport.RMQ, + options: { + urls: [url || process.env.RABBITMQ_URL || 'amqp://localhost:5672'], + queue: queueName, + exchange: 'system_events', + exchangeType: 'topic', + queueOptions: { + durable: true, // Черга не зникає при перезавантаженні RabbitMQ + }, + persistent: true, // Повідомлення зберігаються на диску + }, + }; +}; +exports.getRabbitMQOptions = getRabbitMQOptions; +const getRabbitMQClientOptions = (url) => { + return { + transport: microservices_1.Transport.RMQ, + options: { + urls: [url || process.env.RABBITMQ_URL || 'amqp://localhost:5672'], + queue: 'core_queue', + exchange: 'system_events', + exchangeType: 'topic', + queueOptions: { + durable: true, + }, + }, + }; +}; +exports.getRabbitMQClientOptions = getRabbitMQClientOptions; diff --git a/dist/backend/filters/exception.filter.d.ts b/dist/backend/filters/exception.filter.d.ts new file mode 100644 index 0000000..2fd0e1b --- /dev/null +++ b/dist/backend/filters/exception.filter.d.ts @@ -0,0 +1,4 @@ +import { ExceptionFilter, ArgumentsHost } from '@nestjs/common'; +export declare class AllExceptionsFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost): void; +} diff --git a/dist/backend/filters/exception.filter.js b/dist/backend/filters/exception.filter.js new file mode 100644 index 0000000..3dfec19 --- /dev/null +++ b/dist/backend/filters/exception.filter.js @@ -0,0 +1,42 @@ +"use strict"; +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AllExceptionsFilter = void 0; +const common_1 = require("@nestjs/common"); +const uuid_1 = require("uuid"); +let AllExceptionsFilter = class AllExceptionsFilter { + catch(exception, host) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const status = exception instanceof common_1.HttpException + ? exception.getStatus() + : common_1.HttpStatus.INTERNAL_SERVER_ERROR; + const message = exception instanceof common_1.HttpException + ? exception.getResponse() + : 'Internal server error'; + const serviceName = process.env.SERVICE_NAME || 'unknown-service'; + response.status(status).json({ + success: false, + error: { + code: `ERR_${status}`, + message: typeof message === 'string' ? message : message.message || 'Error', + details: typeof message === 'object' ? message : null, + }, + meta: { + timestamp: Date.now(), + traceId: (0, uuid_1.v4)(), + service: serviceName, + }, + }); + } +}; +exports.AllExceptionsFilter = AllExceptionsFilter; +exports.AllExceptionsFilter = AllExceptionsFilter = __decorate([ + (0, common_1.Catch)() +], AllExceptionsFilter); diff --git a/dist/backend/interceptors/request-context.interceptor.d.ts b/dist/backend/interceptors/request-context.interceptor.d.ts new file mode 100644 index 0000000..4a43328 --- /dev/null +++ b/dist/backend/interceptors/request-context.interceptor.d.ts @@ -0,0 +1,7 @@ +import { NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { AsyncLocalStorage } from 'async_hooks'; +export declare const requestContext: AsyncLocalStorage>; +export declare class RequestContextInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable; +} diff --git a/dist/backend/interceptors/request-context.interceptor.js b/dist/backend/interceptors/request-context.interceptor.js new file mode 100644 index 0000000..54c37bf --- /dev/null +++ b/dist/backend/interceptors/request-context.interceptor.js @@ -0,0 +1,42 @@ +"use strict"; +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.RequestContextInterceptor = exports.requestContext = void 0; +const common_1 = require("@nestjs/common"); +const rxjs_1 = require("rxjs"); +const async_hooks_1 = require("async_hooks"); +exports.requestContext = new async_hooks_1.AsyncLocalStorage(); +let RequestContextInterceptor = class RequestContextInterceptor { + intercept(context, next) { + const request = context.switchToHttp().getRequest(); + const store = new Map(); + // Зберігаємо токен авторизації, якщо він є + const authHeader = request.headers['authorization']; + if (authHeader) { + store.set('authorization', authHeader); + } + // Зберігаємо IP та User-Agent + const ip = request.headers['x-forwarded-for'] || request.ip || request.socket?.remoteAddress; + if (ip) { + store.set('ip', ip); + } + const userAgent = request.headers['user-agent']; + if (userAgent) { + store.set('userAgent', userAgent); + } + return new rxjs_1.Observable(observer => { + exports.requestContext.run(store, () => { + next.handle().subscribe(observer); + }); + }); + } +}; +exports.RequestContextInterceptor = RequestContextInterceptor; +exports.RequestContextInterceptor = RequestContextInterceptor = __decorate([ + (0, common_1.Injectable)() +], RequestContextInterceptor); diff --git a/dist/backend/interceptors/transform.interceptor.d.ts b/dist/backend/interceptors/transform.interceptor.d.ts new file mode 100644 index 0000000..b1b2ca0 --- /dev/null +++ b/dist/backend/interceptors/transform.interceptor.d.ts @@ -0,0 +1,6 @@ +import { NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { ApiResponse } from '../interfaces/communication'; +export declare class TransformInterceptor implements NestInterceptor> { + intercept(context: ExecutionContext, next: CallHandler): Observable>; +} diff --git a/dist/backend/interceptors/transform.interceptor.js b/dist/backend/interceptors/transform.interceptor.js new file mode 100644 index 0000000..25a1272 --- /dev/null +++ b/dist/backend/interceptors/transform.interceptor.js @@ -0,0 +1,30 @@ +"use strict"; +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.TransformInterceptor = void 0; +const common_1 = require("@nestjs/common"); +const operators_1 = require("rxjs/operators"); +const uuid_1 = require("uuid"); +let TransformInterceptor = class TransformInterceptor { + intercept(context, next) { + const serviceName = process.env.SERVICE_NAME || 'unknown-service'; + return next.handle().pipe((0, operators_1.map)((data) => ({ + success: true, + data, + meta: { + timestamp: Date.now(), + traceId: (0, uuid_1.v4)(), + service: serviceName, + }, + }))); + } +}; +exports.TransformInterceptor = TransformInterceptor; +exports.TransformInterceptor = TransformInterceptor = __decorate([ + (0, common_1.Injectable)() +], TransformInterceptor); diff --git a/dist/backend/interfaces/communication.d.ts b/dist/backend/interfaces/communication.d.ts new file mode 100644 index 0000000..d2f033b --- /dev/null +++ b/dist/backend/interfaces/communication.d.ts @@ -0,0 +1,28 @@ +/** + * Стандартний конверт для всіх API відповідей (Синхронно) + */ +export interface ApiResponse { + success: boolean; + data?: T; + error?: { + code: string; + message: string; + details?: any; + }; + meta: { + timestamp: number; + traceId: string; + service: string; + }; +} +/** + * Стандартний конверт для подій (Асинхронно) + */ +export interface SystemEvent { + eventId: string; + pattern: string; + payload: T; + source: string; + timestamp: number; + priority?: 'low' | 'medium' | 'high'; +} diff --git a/dist/backend/interfaces/communication.js b/dist/backend/interfaces/communication.js new file mode 100644 index 0000000..c8ad2e5 --- /dev/null +++ b/dist/backend/interfaces/communication.js @@ -0,0 +1,2 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/dist/backend/services/core-client.service.d.ts b/dist/backend/services/core-client.service.d.ts new file mode 100644 index 0000000..747bdbf --- /dev/null +++ b/dist/backend/services/core-client.service.d.ts @@ -0,0 +1,13 @@ +import { ClientProxy } from '@nestjs/microservices'; +export declare class CoreClientService { + private readonly client; + constructor(client: ClientProxy); + /** + * Запитати у Ядра дані користувача (Синхронно через RabbitMQ RPC) + */ + getUserInfo(userId: number): Promise; + /** + * Повідомити Ядро про подію в плагіні (Асинхронно) + */ + notifyCore(pattern: string, payload: any): Promise; +} diff --git a/dist/backend/services/core-client.service.js b/dist/backend/services/core-client.service.js new file mode 100644 index 0000000..4f1de28 --- /dev/null +++ b/dist/backend/services/core-client.service.js @@ -0,0 +1,54 @@ +"use strict"; +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +var __metadata = (this && this.__metadata) || function (k, v) { + if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); +}; +var __param = (this && this.__param) || function (paramIndex, decorator) { + return function (target, key) { decorator(target, key, paramIndex); } +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CoreClientService = void 0; +const common_1 = require("@nestjs/common"); +const microservices_1 = require("@nestjs/microservices"); +const rxjs_1 = require("rxjs"); +const uuid_1 = require("uuid"); +let CoreClientService = class CoreClientService { + constructor(client) { + this.client = client; + } + /** + * Запитати у Ядра дані користувача (Синхронно через RabbitMQ RPC) + */ + async getUserInfo(userId) { + try { + return await (0, rxjs_1.firstValueFrom)(this.client.send({ cmd: 'get_user_info' }, { userId })); + } + catch (error) { + return null; + } + } + /** + * Повідомити Ядро про подію в плагіні (Асинхронно) + */ + async notifyCore(pattern, payload) { + const event = { + eventId: (0, uuid_1.v4)(), + pattern, + payload, + source: process.env.SERVICE_NAME || 'plugin', + timestamp: Date.now(), + }; + this.client.emit(pattern, event); + } +}; +exports.CoreClientService = CoreClientService; +exports.CoreClientService = CoreClientService = __decorate([ + (0, common_1.Injectable)(), + __param(0, (0, common_1.Inject)('CORE_SERVICE')), + __metadata("design:paramtypes", [microservices_1.ClientProxy]) +], CoreClientService); diff --git a/dist/backend/services/core.service.d.ts b/dist/backend/services/core.service.d.ts new file mode 100644 index 0000000..a9ad96d --- /dev/null +++ b/dist/backend/services/core.service.d.ts @@ -0,0 +1,38 @@ +import { ClientProxy } from '@nestjs/microservices'; +export declare class CoreService { + private readonly client; + private readonly logger; + private readonly serviceName; + private heartbeatInterval; + constructor(client: ClientProxy); + /** + * Спеціальний метод для виконання запитів до Ядра з автоматичним прокиданням авторизації + */ + fetchCore(endpoint: string, options?: RequestInit): Promise; + register(): Promise; + private startHeartbeatLoop; + emit(pattern: string, payload: T): Promise; + auditLog(action: string, resource: string, details?: any): Promise; + getGlobalData(key: string): Promise; + setGlobalData(key: string, value: any): Promise; + deleteGlobalData(key: string): Promise; + sendNotification(payload: { + userId: number | null; + title: string; + message: string; + icon?: string; + image?: string; + details?: any; + }): Promise; + /** + * Отримати назву БД, наданої ядром (має сенс після виклику setupDatabase) + */ + getDbName(): string; + /** + * Зареєструвати БД плагіна в ядрі (створює БД p_, якщо ще не існує). + * Викликати ДО створення NestJS app, щоб TypeORM міг підхопити PLUGIN_DB_NAME. + */ + static setupDatabase(options: { + pluginId: string; + }): Promise; +} diff --git a/dist/backend/services/core.service.js b/dist/backend/services/core.service.js new file mode 100644 index 0000000..4d95df4 --- /dev/null +++ b/dist/backend/services/core.service.js @@ -0,0 +1,311 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { + var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; + if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); + else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; + return c > 3 && r && Object.defineProperty(target, key, r), r; +}; +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +var __metadata = (this && this.__metadata) || function (k, v) { + if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); +}; +var __param = (this && this.__param) || function (paramIndex, decorator) { + return function (target, key) { decorator(target, key, paramIndex); } +}; +var CoreService_1; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CoreService = void 0; +const common_1 = require("@nestjs/common"); +const microservices_1 = require("@nestjs/microservices"); +const uuid_1 = require("uuid"); +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +const request_context_interceptor_1 = require("../interceptors/request-context.interceptor"); +let CoreService = CoreService_1 = class CoreService { + constructor(client) { + this.client = client; + this.logger = new common_1.Logger(CoreService_1.name); + this.serviceName = process.env.SERVICE_NAME || 'plugin'; + this.heartbeatInterval = null; + } + /** + * Спеціальний метод для виконання запитів до Ядра з автоматичним прокиданням авторизації + */ + async fetchCore(endpoint, options = {}) { + const coreUrl = process.env.CORE_BACKEND_URL || 'http://localhost:3001'; + const store = request_context_interceptor_1.requestContext.getStore(); + const authHeader = store?.get('authorization'); + const headers = { + 'Content-Type': 'application/json', + ...(authHeader ? { 'Authorization': authHeader } : {}), + ...(options.headers || {}), + }; + this.logger.log(`[CoreRequest] Sending request to ${endpoint} (Auth: ${!!authHeader})`); + const response = await fetch(`${coreUrl}${endpoint}`, { + ...options, + headers, + }); + if (!response.ok) { + this.logger.error(`[CoreRequest] Request to ${endpoint} failed with status ${response.status}`); + } + return response.json(); + } + // ... (решта методів register, emit, request залишаються без змін) + async register() { + const coreUrl = process.env.CORE_BACKEND_URL || 'http://localhost:3001'; + const possiblePaths = [ + path.resolve(process.cwd(), '..', 'manifest.json'), + path.resolve(process.cwd(), 'manifest.json'), + ]; + let manifestPath = ''; + for (const p of possiblePaths) { + if (fs.existsSync(p)) { + manifestPath = p; + break; + } + } + 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}`); + let registered = false; + while (!registered) { + try { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + if (process.env.PUBLIC_URL) { + let p = '/remoteEntry.js'; + if (manifest.remoteEntry) { + try { + if (manifest.remoteEntry.startsWith('http://') || manifest.remoteEntry.startsWith('https://')) { + p = new URL(manifest.remoteEntry).pathname; + } + else { + p = manifest.remoteEntry; + } + } + catch (e) { + p = manifest.remoteEntry; + } + } + const publicUrl = process.env.PUBLIC_URL.endsWith('/') ? process.env.PUBLIC_URL.slice(0, -1) : process.env.PUBLIC_URL; + const pathname = p.startsWith('/') ? p : '/' + p; + manifest.remoteEntry = `${publicUrl}${pathname}`; + } + manifest.backendUrl = backendUrl; + const response = await fetch(`${coreUrl}/api/plugins/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(manifest), + }); + if (response.ok) { + this.logger.log(`Plugin registered successfully!`); + registered = true; + this.startHeartbeatLoop(manifest.id, backendUrl); + } + else { + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + catch (error) { + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + } + startHeartbeatLoop(pluginId, backendUrl) { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + } + this.heartbeatInterval = setInterval(async () => { + try { + const coreUrl = process.env.CORE_BACKEND_URL || 'http://localhost:3001'; + this.logger.log(`[Heartbeat] Sending heartbeat for ${pluginId}`); + const response = await fetch(`${coreUrl}/api/plugins/heartbeat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: pluginId, backendUrl }), + }); + if (response.ok) { + const resBody = await response.json(); + const data = resBody && resBody.data !== undefined ? resBody.data : resBody; + if (data && data.registered === false) { + this.logger.warn(`[Heartbeat] Core does not recognize plugin ${pluginId}. Re-registering.`); + this.register(); + } + else { + this.logger.log(`[Heartbeat] Heartbeat acknowledged by core.`); + } + } + else { + this.logger.error(`[Heartbeat] Failed to send heartbeat. Status: ${response.status}`); + } + } + catch (error) { + this.logger.error(`[Heartbeat] Error sending heartbeat:`, error.message || error); + } + }, 30 * 1000); // 30 seconds + } + async emit(pattern, payload) { + const event = { + eventId: (0, uuid_1.v4)(), + pattern, + payload, + source: this.serviceName, + timestamp: Date.now(), + }; + this.client.emit(pattern, event).subscribe({ + error: (err) => this.logger.error(`Failed to emit event ${pattern}:`, err), + }); + } + async auditLog(action, resource, details) { + const store = request_context_interceptor_1.requestContext.getStore(); + const authHeader = store?.get('authorization'); + const ipAddress = store?.get('ip'); + const userAgent = store?.get('userAgent'); + let userId; + if (authHeader && authHeader.startsWith('Bearer ')) { + try { + const token = authHeader.split(' ')[1]; + const base64Url = token.split('.')[1]; + const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); + const jsonPayload = Buffer.from(base64, 'base64').toString(); + const payload = JSON.parse(jsonPayload); + userId = payload.sub || payload.userId || payload.id; + } + catch (e) { + this.logger.error('Failed to decode JWT for audit log', e); + } + } + await this.emit('system.audit.log', { + userId, + action, + resource, + details, + ipAddress, + userAgent, + }); + } + async getGlobalData(key) { + const response = await this.fetchCore(`/api/store/${key}`, { + headers: { + 'x-plugin-id': this.serviceName, + }, + }); + if (response.success) { + if (response.data === null || response.data === undefined) { + return null; + } + try { + return JSON.parse(response.data); + } + catch (e) { + return response.data; + } + } + return null; + } + async setGlobalData(key, value) { + const stringValue = typeof value === 'string' ? value : JSON.stringify(value); + await this.fetchCore(`/api/store/${key}`, { + method: 'POST', + body: JSON.stringify({ value: stringValue }), + headers: { + 'x-plugin-id': this.serviceName, + }, + }); + } + async deleteGlobalData(key) { + await this.fetchCore(`/api/store/${key}`, { + method: 'DELETE', + headers: { + 'x-plugin-id': this.serviceName, + }, + }); + } + async sendNotification(payload) { + await this.emit('system.notification.send', { + userId: payload.userId, + title: payload.title, + message: payload.message, + icon: payload.icon, + image: payload.image, + details: payload.details, + }); + } + /** + * Отримати назву БД, наданої ядром (має сенс після виклику setupDatabase) + */ + getDbName() { + return process.env.PLUGIN_DB_NAME || process.env.DB_DATABASE || 'ultimate_crm'; + } + /** + * Зареєструвати БД плагіна в ядрі (створює БД p_, якщо ще не існує). + * Викликати ДО створення NestJS app, щоб TypeORM міг підхопити PLUGIN_DB_NAME. + */ + static async setupDatabase(options) { + const coreUrl = process.env.CORE_BACKEND_URL || 'http://localhost:3001'; + const response = await fetch(`${coreUrl}/api/db/setup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pluginId: options.pluginId }), + }); + if (!response.ok) { + 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.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; + } +}; +exports.CoreService = CoreService; +exports.CoreService = CoreService = CoreService_1 = __decorate([ + (0, common_1.Injectable)(), + __param(0, (0, common_1.Inject)('RABBITMQ_CLIENT')), + __metadata("design:paramtypes", [microservices_1.ClientProxy]) +], CoreService); diff --git a/dist/cli/index.d.ts b/dist/cli/index.d.ts new file mode 100644 index 0000000..b798801 --- /dev/null +++ b/dist/cli/index.d.ts @@ -0,0 +1,2 @@ +#!/usr/bin/env node +export {}; diff --git a/dist/cli/index.js b/dist/cli/index.js new file mode 100644 index 0000000..6230ccb --- /dev/null +++ b/dist/cli/index.js @@ -0,0 +1,673 @@ +#!/usr/bin/env node +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); +Object.defineProperty(exports, "__esModule", { value: true }); +const fs = __importStar(require("fs")); +const path = __importStar(require("path")); +function toSnakeCase(name) { + return name.replace(/[-]/g, '_'); +} +function toPascalCase(name) { + return name + .split(/[-_]/) + .map(s => s.charAt(0).toUpperCase() + s.slice(1)) + .join(' '); +} +function humanLabel(name) { + return name + .split(/[-_]/) + .map(s => s.charAt(0).toUpperCase() + s.slice(1)) + .join(' '); +} +function buildTemplates(name, port) { + const snake = toSnakeCase(name); + const label = humanLabel(name); + const routePath = `/${name}`; + return [ + { + relativePath: 'manifest.json', + content: JSON.stringify({ + id: name, + name: snake, + label: { en: label, uk: label }, + icon: 'Puzzle', + routePath, + remoteEntry: `http://localhost:${port}/remoteEntry.js`, + exposedModule: './App', + roles: ['admin'], + database: 'self', + }, null, 2) + '\n', + }, + { + relativePath: 'k8s-plugin.yaml', + content: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${name} + namespace: crm +spec: + replicas: 1 + selector: + matchLabels: + app: ${name} + template: + metadata: + labels: + app: ${name} + spec: + containers: + - name: backend + image: crm-plugins/${name}:latest + ports: + - containerPort: ${port} + env: + - name: CORE_BACKEND_URL + value: "http://core-backend.crm.svc.cluster.local:3001" + - name: PUBLIC_URL + value: "https://crm.yourdomain.com/modules/${name}" + +--- +apiVersion: v1 +kind: Service +metadata: + name: ${name} + namespace: crm +spec: + selector: + app: ${name} + ports: + - port: 80 + targetPort: ${port} +`, + }, + { + relativePath: 'backend/package.json', + content: JSON.stringify({ + name: `${name}-backend`, + version: '0.0.1', + description: `CRM Plugin Backend - ${label}`, + private: true, + scripts: { + build: 'nest build', + start: 'nest start', + 'start:dev': 'nest start --watch', + 'start:prod': 'node dist/main', + }, + dependencies: { + '@nestjs/common': '^10.0.0', + '@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': 'git+https://gitea.ss.3w.com.ua/erp/erp-plugin-sdk.git', + pg: '^8.12.0', + 'reflect-metadata': '^0.1.13', + rxjs: '^7.8.1', + typeorm: '^0.3.20', + uuid: '^9.0.1', + }, + devDependencies: { + '@nestjs/cli': '^10.0.0', + '@nestjs/schematics': '^10.0.0', + '@types/node': '^20.3.1', + typescript: '^5.1.3', + }, + }, null, 2) + '\n', + }, + { + relativePath: 'backend/tsconfig.json', + content: JSON.stringify({ + compilerOptions: { + module: 'commonjs', + declaration: true, + removeComments: true, + emitDecoratorMetadata: true, + experimentalDecorators: true, + allowSyntheticDefaultImports: true, + target: 'ES2021', + sourceMap: true, + outDir: './dist', + baseUrl: './', + incremental: true, + skipLibCheck: true, + strictNullChecks: false, + noImplicitAny: false, + strictBindCallApply: false, + forceConsistentCasingInFileNames: false, + noFallthroughCasesInSwitch: false, + }, + }, null, 2) + '\n', + }, + { + relativePath: 'backend/nest-cli.json', + content: JSON.stringify({ + collection: '@nestjs/schematics', + sourceRoot: 'src', + compilerOptions: { + deleteOutDir: true, + }, + }, null, 2) + '\n', + }, + { + relativePath: 'backend/Dockerfile', + content: `FROM node:18-alpine + +WORKDIR /app + +COPY backend/package*.json ./backend/ +COPY frontend/package*.json ./frontend/ + +RUN apk add --no-cache git +RUN cd backend && npm install +RUN cd frontend && npm install + +COPY . . + +RUN cd backend && npm run build +RUN cd frontend && npm run build + +EXPOSE ${port} + +WORKDIR /app/backend +CMD ["npm", "run", "start:prod"] +`, + }, + { + relativePath: 'backend/src/main.ts', + content: `import { CoreService, RequestContextInterceptor } from 'erp-plugin-sdk'; +import { NestFactory } from '@nestjs/core'; +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()); + + const express = require('express'); + const distPath = path.join(process.cwd(), '..', 'frontend', 'dist'); + + app.use('/', express.static(distPath, { + setHeaders: (res: any) => { + res.set('Access-Control-Allow-Origin', '*'); + } + })); + + app.use((req: any, res: any, next: any) => { + logger.log(\`Incoming request: \${req.method} \${req.url}\`); + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE,OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Accept, Authorization'); + + if (req.method === 'OPTIONS') { + return res.sendStatus(200); + } + next(); + }); + + const { getRabbitMQOptions } = await import('erp-plugin-sdk'); + app.connectMicroservice(getRabbitMQOptions('plugin_${snake}_queue')); + + app.enableCors(); + await app.startAllMicroservices(); + + const port = process.env.PORT || ${port}; + await app.listen(port); + + const coreService = app.get(CoreService); + coreService.register(); + + logger.log(\`Plugin Service is running on: http://localhost:\${port}\`); +} + +bootstrap().catch((err) => { + console.error('Failed to bootstrap plugin:', err); + process.exit(1); +}); +`, + }, + { + relativePath: 'backend/src/app.module.ts', + 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', + ...getRabbitMQClientOptions(), + }, + ]), + ], + controllers: [AppController, PingController, ItemController], + providers: [CoreService, ItemService], + exports: [CoreService], +}) +export class AppModule {} +`, + }, + { + relativePath: 'backend/src/app.controller.ts', + content: `import { Controller, Get } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; + +@Controller('debug') +export class AppController { + @Get('files') + listFiles() { + const distPath = path.join(process.cwd(), '..', 'frontend', 'dist'); + try { + const files = fs.readdirSync(distPath); + return { + path: distPath, + exists: fs.existsSync(distPath), + files + }; + } catch (e: any) { + return { error: e.message, path: distPath }; + } + } +} +`, + }, + { + relativePath: 'backend/src/ping.controller.ts', + content: `import { Controller, Get } from '@nestjs/common'; + +@Controller('ping') +export class PingController { + @Get() + ping() { + 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 { + return this.service.findAll(); + } + + @Get(':id') + async findOne(@Param('id') id: string): Promise { + return this.service.findOne(id); + } + + @Post() + async create(@Body() data: Partial): Promise { + return this.service.create(data); + } + + @Put(':id') + async update(@Param('id') id: string, @Body() data: Partial): Promise { + return this.service.update(id, data); + } + + @Delete(':id') + async remove(@Param('id') id: string): Promise { + 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, + ) {} + + async findAll(): Promise { + return this.repo.find(); + } + + async findOne(id: string): Promise { + const item = await this.repo.findOne({ where: { id } }); + if (!item) throw new NotFoundException(\`Item \${id} not found\`); + return item; + } + + async create(data: Partial): Promise { + const item = this.repo.create(data); + return this.repo.save(item); + } + + async update(id: string, data: Partial): Promise { + await this.repo.update(id, data); + return this.findOne(id); + } + + async remove(id: string): Promise { + 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; +} +`, + }, + { + relativePath: 'frontend/package.json', + content: JSON.stringify({ + name: `${name}-ui`, + private: true, + version: '0.1.0', + type: 'module', + scripts: { + dev: 'vite', + build: 'tsc && vite build', + preview: 'vite preview', + }, + dependencies: { + react: '^18.3.1', + 'react-dom': '^18.3.1', + 'react-router-dom': '^6.23.1', + 'lucide-react': '^0.378.0', + 'framer-motion': '^11.2.10', + }, + devDependencies: { + '@originjs/vite-plugin-federation': '^1.3.5', + '@types/react': '^18.3.3', + '@types/react-dom': '^18.3.0', + '@vitejs/plugin-react': '^4.3.1', + typescript: '^5.2.2', + vite: '^5.3.1', + }, + }, null, 2) + '\n', + }, + { + relativePath: 'frontend/tsconfig.json', + content: JSON.stringify({ + compilerOptions: { + target: 'ESNext', + useDefineForClassFields: true, + lib: ['DOM', 'DOM.Iterable', 'ESNext'], + allowJs: false, + skipLibCheck: true, + esModuleInterop: false, + allowSyntheticDefaultImports: true, + strict: true, + forceConsistentCasingInFileNames: true, + module: 'ESNext', + moduleResolution: 'Node', + resolveJsonModule: true, + isolatedModules: true, + noEmit: true, + jsx: 'react-jsx', + }, + include: ['src'], + references: [{ path: './tsconfig.node.json' }], + }, null, 2) + '\n', + }, + { + relativePath: 'frontend/tsconfig.node.json', + content: JSON.stringify({ + composite: true, + module: 'ESNext', + moduleResolution: 'Node', + allowSyntheticDefaultImports: true, + }, null, 2) + '\n', + }, + { + relativePath: 'frontend/vite.config.ts', + content: `import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import federation from '@originjs/vite-plugin-federation'; + +export default defineConfig({ + plugins: [ + react(), + federation({ + name: '${snake}', + filename: 'remoteEntry.js', + remotes: { + host: 'http://localhost:3000/assets/remoteEntry.js' + }, + exposes: { + './App': './src/App.tsx', + }, + shared: { + react: { singleton: true }, + 'react-dom': { singleton: true }, + 'react-router-dom': { singleton: true }, + 'lucide-react': { singleton: true }, + 'framer-motion': { singleton: true } + } + }) + ], + build: { + modulePreload: false, + target: 'esnext', + minify: false, + cssCodeSplit: false, + assetsDir: '' + }, + server: { + port: ${port}, + cors: true + } +}); +`, + }, + { + relativePath: 'frontend/index.html', + content: ` + + + + + ${label} + + +
+ + + +`, + }, + { + relativePath: 'frontend/src/App.tsx', + content: `import React from 'react'; +import { Puzzle } from 'lucide-react'; +// @ts-ignore +import { Card, Typography } from 'host/UIKit'; + +interface AppProps { + language?: string; +} + +const App: React.FC = ({ language = 'en' }) => { + return ( + +
+ + ${label} + + {language === 'uk' + ? 'Плагін успішно створено та підключено до CRM.' + : 'Plugin successfully created and connected to CRM.'} + +
+
+ ); +}; + +export default App; +`, + }, + ]; +} +async function main() { + const args = process.argv.slice(2); + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + console.log(`Usage: create-erp-plugin [--port ] + +Create a new ERP plugin scaffold in the current directory. + +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 +`); + process.exit(0); + } + const name = args[0]; + const portIndex = args.indexOf('--port') !== -1 ? args.indexOf('--port') : args.indexOf('-p'); + const port = portIndex !== -1 ? parseInt(args[portIndex + 1], 10) : 3002; + if (port < 1024 || port > 65535) { + console.error('Error: Port must be between 1024 and 65535'); + process.exit(1); + } + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) { + console.error('Error: Plugin name must be in kebab-case (e.g., "my-plugin")'); + process.exit(1); + } + const targetDir = path.join(process.cwd(), name); + if (fs.existsSync(targetDir)) { + console.error(`Error: Directory "${name}" already exists`); + process.exit(1); + } + const templates = buildTemplates(name, port); + for (const tpl of templates) { + const filePath = path.join(targetDir, tpl.relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, tpl.content, 'utf-8'); + } + console.log(`\u2705 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) => { + console.error('Error:', err.message); + process.exit(1); +}); diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..81dc6f7 --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,7 @@ +export * from './backend/services/core.service'; +export * from './backend/services/core-client.service'; +export * from './backend/interfaces/communication'; +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'; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..aa73025 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,24 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +/// +__exportStar(require("./backend/services/core.service"), exports); +__exportStar(require("./backend/services/core-client.service"), exports); +__exportStar(require("./backend/interfaces/communication"), exports); +__exportStar(require("./backend/filters/exception.filter"), exports); +__exportStar(require("./backend/interceptors/request-context.interceptor"), exports); +__exportStar(require("./backend/interceptors/transform.interceptor"), exports); +__exportStar(require("./backend/config/rabbitmq.config"), exports); diff --git a/package.json b/package.json index 2f62e04..eb8b5cb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ }, "scripts": { "build": "tsc", - "prepare": "npm run build", + "test": "echo \"Error: no test specified\" && exit 1" }, "repository": {