build: commit dist/ and remove prepare script
- dist/ removed from .gitignore so compiled output ships with git - prepare script removed (no build step on npm install) - tag v1.0.1 reflects this change
This commit is contained in:
parent
788acab14e
commit
935625946f
|
|
@ -1,3 +1,2 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { ExceptionFilter, ArgumentsHost } from '@nestjs/common';
|
||||||
|
export declare class AllExceptionsFilter implements ExceptionFilter {
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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<Map<string, string>>;
|
||||||
|
export declare class RequestContextInterceptor implements NestInterceptor {
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<any>;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ApiResponse } from '../interfaces/communication';
|
||||||
|
export declare class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
|
||||||
|
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>>;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* Стандартний конверт для всіх API відповідей (Синхронно)
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
details?: any;
|
||||||
|
};
|
||||||
|
meta: {
|
||||||
|
timestamp: number;
|
||||||
|
traceId: string;
|
||||||
|
service: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Стандартний конверт для подій (Асинхронно)
|
||||||
|
*/
|
||||||
|
export interface SystemEvent<T = any> {
|
||||||
|
eventId: string;
|
||||||
|
pattern: string;
|
||||||
|
payload: T;
|
||||||
|
source: string;
|
||||||
|
timestamp: number;
|
||||||
|
priority?: 'low' | 'medium' | 'high';
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
|
@ -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<any>;
|
||||||
|
/**
|
||||||
|
* Повідомити Ядро про подію в плагіні (Асинхронно)
|
||||||
|
*/
|
||||||
|
notifyCore(pattern: string, payload: any): Promise<void>;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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<any>;
|
||||||
|
register(): Promise<void>;
|
||||||
|
private startHeartbeatLoop;
|
||||||
|
emit<T>(pattern: string, payload: T): Promise<void>;
|
||||||
|
auditLog(action: string, resource: string, details?: any): Promise<void>;
|
||||||
|
getGlobalData<T = string>(key: string): Promise<T | null>;
|
||||||
|
setGlobalData(key: string, value: any): Promise<void>;
|
||||||
|
deleteGlobalData(key: string): Promise<void>;
|
||||||
|
sendNotification(payload: {
|
||||||
|
userId: number | null;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
icon?: string;
|
||||||
|
image?: string;
|
||||||
|
details?: any;
|
||||||
|
}): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Отримати назву БД, наданої ядром (має сенс після виклику setupDatabase)
|
||||||
|
*/
|
||||||
|
getDbName(): string;
|
||||||
|
/**
|
||||||
|
* Зареєструвати БД плагіна в ядрі (створює БД p_<id>, якщо ще не існує).
|
||||||
|
* Викликати ДО створення NestJS app, щоб TypeORM міг підхопити PLUGIN_DB_NAME.
|
||||||
|
*/
|
||||||
|
static setupDatabase(options: {
|
||||||
|
pluginId: string;
|
||||||
|
}): Promise<string>;
|
||||||
|
}
|
||||||
|
|
@ -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_<id>, якщо ще не існує).
|
||||||
|
* Викликати ДО створення 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);
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
export {};
|
||||||
|
|
@ -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<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;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>${label}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/App.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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<AppProps> = ({ language = 'en' }) => {
|
||||||
|
return (
|
||||||
|
<Card className="plugin-card" title={language === 'uk' ? '${label}' : '${label}'}>
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px 0' }}>
|
||||||
|
<Puzzle size={48} color="var(--accent)" style={{ marginBottom: '20px' }} />
|
||||||
|
<Typography.H2>${label}</Typography.H2>
|
||||||
|
<Typography.P>
|
||||||
|
{language === 'uk'
|
||||||
|
? 'Плагін успішно створено та підключено до CRM.'
|
||||||
|
: 'Plugin successfully created and connected to CRM.'}
|
||||||
|
</Typography.P>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 <plugin-name> [--port <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);
|
||||||
|
});
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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 });
|
||||||
|
/// <reference path="./frontend/uikit.d.ts" />
|
||||||
|
__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);
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"prepare": "npm run build",
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue