commit 97e680f87c13a17d3ef47524251d256e39c6663b Author: Кирилл Осипков Date: Fri May 22 14:40:26 2026 +0300 Initial commit of ERP Plugin SDK diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08dc7b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1228768 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,491 @@ +{ + "name": "erp-plugin-sdk", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "erp-plugin-sdk", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@nestjs/common": "^11.1.23", + "@nestjs/core": "^11.1.23", + "@nestjs/microservices": "^11.1.23", + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + "typescript": "^6.0.3", + "uuid": "^14.0.0" + } + }, + "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", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@lukeed/csprng": { + "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" + } + }, + "node_modules/@nestjs/common": { + "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", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/core": { + "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": { + "@nuxt/opencollective": "0.4.1", + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/microservices": { + "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", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@grpc/grpc-js": "*", + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "amqp-connection-manager": "*", + "amqplib": "*", + "cache-manager": "*", + "ioredis": "*", + "kafkajs": "*", + "mqtt": "*", + "nats": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@grpc/grpc-js": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + }, + "amqp-connection-manager": { + "optional": true + }, + "amqplib": { + "optional": true + }, + "cache-manager": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "kafkajs": { + "optional": true + }, + "mqtt": { + "optional": true + }, + "nats": { + "optional": true + } + } + }, + "node_modules/@nuxt/opencollective": { + "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" + }, + "bin": { + "opencollective": "bin/opencollective.js" + }, + "engines": { + "node": "^14.18.0 || >=16.10.0", + "npm": ">=5.10.0" + } + }, + "node_modules/@tokenizer/inflate": { + "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", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "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/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" + } + }, + "node_modules/@types/react": { + "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" + } + }, + "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" + } + }, + "node_modules/csstype": { + "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" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/fast-safe-stringify": { + "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", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/ieee754": { + "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", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/iterare": { + "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" + } + }, + "node_modules/load-esm": { + "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", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, + "node_modules/ms": { + "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/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/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" + } + }, + "node_modules/strtok3": { + "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" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/token-types": { + "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", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tslib": { + "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", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uid": { + "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" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "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" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "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/uuid": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ea2ff92 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "erp-plugin-sdk", + "version": "1.0.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://gitea.ss.3w.com.ua/erp/erp-plugin-sdk.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@nestjs/common": "^11.1.23", + "@nestjs/core": "^11.1.23", + "@nestjs/microservices": "^11.1.23", + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", + "typescript": "^6.0.3", + "uuid": "^14.0.0" + } +} diff --git a/src/backend/config/rabbitmq.config.ts b/src/backend/config/rabbitmq.config.ts new file mode 100644 index 0000000..d0da1d6 --- /dev/null +++ b/src/backend/config/rabbitmq.config.ts @@ -0,0 +1,15 @@ +import { Transport, MicroserviceOptions } from '@nestjs/microservices'; + +export const getRabbitMQOptions = (queueName: string, url?: string): MicroserviceOptions => { + return { + transport: Transport.RMQ, + options: { + urls: [url || process.env.RABBITMQ_URL || 'amqp://localhost:5672'], + queue: queueName, + queueOptions: { + durable: true, // Черга не зникає при перезавантаженні RabbitMQ + }, + persistent: true, // Повідомлення зберігаються на диску + }, + }; +}; diff --git a/src/backend/filters/exception.filter.ts b/src/backend/filters/exception.filter.ts new file mode 100644 index 0000000..cf61f94 --- /dev/null +++ b/src/backend/filters/exception.filter.ts @@ -0,0 +1,43 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; + +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + const message = + exception instanceof 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 as any).message || 'Error', + details: typeof message === 'object' ? message : null, + }, + meta: { + timestamp: Date.now(), + traceId: uuidv4(), + service: serviceName, + }, + }); + } +} diff --git a/src/backend/interceptors/request-context.interceptor.ts b/src/backend/interceptors/request-context.interceptor.ts new file mode 100644 index 0000000..40be3f8 --- /dev/null +++ b/src/backend/interceptors/request-context.interceptor.ts @@ -0,0 +1,35 @@ +import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { AsyncLocalStorage } from 'async_hooks'; + +export const requestContext = new AsyncLocalStorage>(); + +@Injectable() +export class RequestContextInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + 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 Observable(observer => { + requestContext.run(store, () => { + next.handle().subscribe(observer); + }); + }); + } +} diff --git a/src/backend/interceptors/transform.interceptor.ts b/src/backend/interceptors/transform.interceptor.ts new file mode 100644 index 0000000..c42e3b9 --- /dev/null +++ b/src/backend/interceptors/transform.interceptor.ts @@ -0,0 +1,32 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ApiResponse } from '../interfaces/communication'; +import { v4 as uuidv4 } from 'uuid'; + +@Injectable() +export class TransformInterceptor implements NestInterceptor> { + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable> { + const serviceName = process.env.SERVICE_NAME || 'unknown-service'; + + return next.handle().pipe( + map((data) => ({ + success: true, + data, + meta: { + timestamp: Date.now(), + traceId: uuidv4(), + service: serviceName, + }, + })), + ); + } +} diff --git a/src/backend/interfaces/communication.ts b/src/backend/interfaces/communication.ts new file mode 100644 index 0000000..88d0d86 --- /dev/null +++ b/src/backend/interfaces/communication.ts @@ -0,0 +1,29 @@ +/** + * Стандартний конверт для всіх 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; // Наприклад: 'user.login', 'transaction.created' + payload: T; + source: string; // Назва сервісу-відправника + timestamp: number; + priority?: 'low' | 'medium' | 'high'; +} diff --git a/src/backend/services/core-client.service.ts b/src/backend/services/core-client.service.ts new file mode 100644 index 0000000..8fd9701 --- /dev/null +++ b/src/backend/services/core-client.service.ts @@ -0,0 +1,39 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { ClientProxy } from '@nestjs/microservices'; +import { firstValueFrom } from 'rxjs'; +import { SystemEvent } from '../interfaces/communication'; +import { v4 as uuidv4 } from 'uuid'; + +@Injectable() +export class CoreClientService { + constructor( + @Inject('CORE_SERVICE') private readonly client: ClientProxy, + ) {} + + /** + * Запитати у Ядра дані користувача (Синхронно через RabbitMQ RPC) + */ + async getUserInfo(userId: number) { + try { + return await firstValueFrom( + this.client.send({ cmd: 'get_user_info' }, { userId }) + ); + } catch (error) { + return null; + } + } + + /** + * Повідомити Ядро про подію в плагіні (Асинхронно) + */ + async notifyCore(pattern: string, payload: any) { + const event: SystemEvent = { + eventId: uuidv4(), + pattern, + payload, + source: process.env.SERVICE_NAME || 'plugin', + timestamp: Date.now(), + }; + this.client.emit(pattern, event); + } +} diff --git a/src/backend/services/core.service.ts b/src/backend/services/core.service.ts new file mode 100644 index 0000000..a92461a --- /dev/null +++ b/src/backend/services/core.service.ts @@ -0,0 +1,214 @@ +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { ClientProxy } from '@nestjs/microservices'; +import { firstValueFrom } from 'rxjs'; +import { SystemEvent } from '../interfaces/communication'; +import { v4 as uuidv4 } from 'uuid'; +import * as fs from 'fs'; +import * as path from 'path'; +import { requestContext } from '../interceptors/request-context.interceptor'; + +@Injectable() +export class CoreService { + private readonly logger = new Logger(CoreService.name); + private readonly serviceName = process.env.SERVICE_NAME || 'plugin'; + private heartbeatInterval: any = null; + + constructor( + @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 } : {}), + ...((options.headers as any) || {}), + }; + + 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(): Promise { + 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) { + manifest.remoteEntry = `${process.env.PUBLIC_URL}/remoteEntry.js`; + } + 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)); + } + } + } + + private startHeartbeatLoop(pluginId: string, backendUrl: string) { + 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: any) { + this.logger.error(`[Heartbeat] Error sending heartbeat:`, error.message || error); + } + }, 30 * 1000); // 30 seconds + } + + async emit(pattern: string, payload: T): Promise { + const event: SystemEvent = { + eventId: uuidv4(), + 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: string, resource: string, details?: any): Promise { + const store = requestContext.getStore(); + const authHeader = store?.get('authorization'); + const ipAddress = store?.get('ip'); + const userAgent = store?.get('userAgent'); + let userId: number | undefined; + + 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: string): Promise { + 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) as T; + } catch (e) { + return response.data as unknown as T; + } + } + return null; + } + + async setGlobalData(key: string, value: any): Promise { + 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: string): Promise { + await this.fetchCore(`/api/store/${key}`, { + method: 'DELETE', + headers: { + 'x-plugin-id': this.serviceName, + }, + }); + } +} diff --git a/src/frontend/uikit.d.ts b/src/frontend/uikit.d.ts new file mode 100644 index 0000000..0d40abf --- /dev/null +++ b/src/frontend/uikit.d.ts @@ -0,0 +1,31 @@ +import React from 'react'; + +declare module 'host/UIKit' { + export interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; + } + export const Button: React.FC; + + export interface InputProps extends React.InputHTMLAttributes { + label?: string; + icon?: React.ReactNode; + error?: string; + } + export const Input: React.FC; + + export interface CardProps { + title?: React.ReactNode; + children?: React.ReactNode; + className?: string; + style?: React.CSSProperties; + } + export const Card: React.FC; + + export const Typography: { + H1: React.FC>; + H2: React.FC>; + H3: React.FC>; + P: React.FC>; + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..276d0a6 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,8 @@ +/// +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/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..780a531 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "CommonJS", + "moduleResolution": "node", + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "types": ["node", "react"], + "ignoreDeprecations": "6.0" + }, + "include": ["src/**/*"] +}