feat(api): módulo del API Gateway (reportes Crystal): sesión y cola propias, matrícula de reportes y ejecución con PDF
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
74743503f0
commit
2a29c63e7d
13 files changed
+774
-2
No files matched your search
@@ -3,13 +3,14 @@ import { AuditAdminModule } from './audit/audit-admin.module.js';
|
||||
import { AuthModule } from './auth/auth.module.js';
|
||||
import { AppConfigModule } from './config/config.module.js';
|
||||
import { DatabaseModule } from './database/database.module.js';
|
||||
import { GatewayModule } from './gateway/gateway.module.js';
|
||||
import { HealthController } from './health/health.controller.js';
|
||||
import { PermissionsModule } from './permissions/permissions.module.js';
|
||||
import { SapModule } from './sap/sap.module.js';
|
||||
import { SlModule } from './sl/sl.module.js';
|
||||
|
||||
@Module({
|
||||
imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule],
|
||||
imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule, GatewayModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -56,4 +56,29 @@ describe('validateEnv', () => {
|
||||
).toThrow(/producción/);
|
||||
expect(validateEnv({ ...sap, SAPB1_SL_VERIFY_SSL: 'false' }).SAPB1_SL_VERIFY_SSL).toBe(false);
|
||||
});
|
||||
|
||||
describe('API Gateway', () => {
|
||||
it('es opcional: sin URL queda sin configurar y con valores por defecto', () => {
|
||||
const env = validateEnv(sap);
|
||||
expect(env.APIGATEWAY_URL).toBeUndefined();
|
||||
expect(env.APIGATEWAY_TIMEOUT).toBe(60);
|
||||
expect(env.APIGATEWAY_MAX_CONCURRENCY).toBe(2);
|
||||
expect(env.APIGATEWAY_VERIFY_SSL).toBe(true);
|
||||
});
|
||||
|
||||
it('con URL pide usuario, clave y base, y quita la barra final', () => {
|
||||
expect(() => validateEnv({ ...sap, APIGATEWAY_URL: 'https://gw.local:60000/' })).toThrow(/APIGATEWAY_USER/);
|
||||
const env = validateEnv({ ...sap, APIGATEWAY_URL: 'https://gw.local:60000/', APIGATEWAY_USER: 'u', APIGATEWAY_PASSWORD: 'p', APIGATEWAY_COMPANYDB: 'DB' });
|
||||
expect(env.APIGATEWAY_URL).toBe('https://gw.local:60000');
|
||||
});
|
||||
|
||||
it('un valor vacío del .env cuenta como ausente', () => {
|
||||
expect(validateEnv({ ...sap, APIGATEWAY_URL: '', APIGATEWAY_USER: '' }).APIGATEWAY_URL).toBeUndefined();
|
||||
});
|
||||
|
||||
it('no se permite desactivar la validación SSL en producción', () => {
|
||||
expect(() => validateEnv({ ...sap, NODE_ENV: 'production', APIGATEWAY_VERIFY_SSL: 'false' })).toThrow(/APIGATEWAY_VERIFY_SSL/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,21 @@ export const envSchema = z
|
||||
// CA adicional de confianza (PEM): certificado autofirmado del Service
|
||||
// Layer o CA de un firewall que inspecciona SSL. Opcional.
|
||||
SAPB1_SL_CA_FILE: z.preprocess(emptyToUndefined, z.string().optional()),
|
||||
// --- SAP Business One API Gateway (reportes Crystal): opcional; sin URL el módulo queda sin configurar ---
|
||||
APIGATEWAY_URL: z.preprocess(emptyToUndefined, z.url().optional()).transform((v) => v?.replace(/\/+$/, '')),
|
||||
APIGATEWAY_USER: z.preprocess(emptyToUndefined, z.string().optional()),
|
||||
APIGATEWAY_PASSWORD: z.preprocess(emptyToUndefined, z.string().optional()),
|
||||
APIGATEWAY_COMPANYDB: z.preprocess(emptyToUndefined, z.string().optional()),
|
||||
APIGATEWAY_DBINSTANCE: z.preprocess(emptyToUndefined, z.string().optional()),
|
||||
// Espera máxima (segundos) de cada llamada: generar un reporte puede tardar.
|
||||
APIGATEWAY_TIMEOUT: z.coerce.number().positive().default(60),
|
||||
// Reportes que se generan a la vez; el resto espera su turno.
|
||||
APIGATEWAY_MAX_CONCURRENCY: z.coerce.number().int().positive().default(2),
|
||||
APIGATEWAY_CA_FILE: z.preprocess(emptyToUndefined, z.string().optional()),
|
||||
APIGATEWAY_VERIFY_SSL: z
|
||||
.enum(['true', 'false'])
|
||||
.default('true')
|
||||
.transform((v) => v === 'true'),
|
||||
SAPB1_SL_VERIFY_SSL: z
|
||||
.enum(['true', 'false'])
|
||||
.default('true')
|
||||
@@ -63,6 +78,15 @@ export const envSchema = z
|
||||
message: `no existe el archivo ${resolve(env.SAPB1_SL_CA_FILE)}`,
|
||||
});
|
||||
}
|
||||
if (env.APIGATEWAY_CA_FILE && !existsSync(resolve(env.APIGATEWAY_CA_FILE))) {
|
||||
ctx.addIssue({ code: 'custom', path: ['APIGATEWAY_CA_FILE'], message: `no existe el archivo ${resolve(env.APIGATEWAY_CA_FILE)}` });
|
||||
}
|
||||
if (env.APIGATEWAY_URL && !(env.APIGATEWAY_USER && env.APIGATEWAY_PASSWORD && env.APIGATEWAY_COMPANYDB)) {
|
||||
ctx.addIssue({ code: 'custom', path: ['APIGATEWAY_URL'], message: 'con APIGATEWAY_URL hacen falta APIGATEWAY_USER, APIGATEWAY_PASSWORD y APIGATEWAY_COMPANYDB' });
|
||||
}
|
||||
if (env.NODE_ENV === 'production' && !env.APIGATEWAY_VERIFY_SSL) {
|
||||
ctx.addIssue({ code: 'custom', path: ['APIGATEWAY_VERIFY_SSL'], message: 'no se permite desactivar la validación SSL en producción; use APIGATEWAY_CA_FILE' });
|
||||
}
|
||||
if (env.NODE_ENV === 'production' && !env.SAPB1_SL_VERIFY_SSL) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { rootCertificates } from 'node:tls';
|
||||
import { BadGatewayException, GatewayTimeoutException, HttpException, Injectable, Logger, OnModuleDestroy, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { Agent, request } from 'undici';
|
||||
import { AppConfigService } from '../config/app-config.service.js';
|
||||
|
||||
interface Session {
|
||||
cookie: string;
|
||||
}
|
||||
|
||||
/** Respuesta ya leída de una llamada al API Gateway. */
|
||||
export interface GatewayResponse {
|
||||
status: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cliente del API Gateway de SAP (Crystal Reports): su propio login y su propia sesión, separados del Service Layer, y
|
||||
* una cola propia: solo `APIGATEWAY_MAX_CONCURRENCY` llamadas a la vez (generar un reporte pesa), el resto espera turno.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GatewayClientService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(GatewayClientService.name);
|
||||
private session?: Session;
|
||||
private loginInFlight?: Promise<Session>;
|
||||
private active = 0;
|
||||
private readonly waiting: (() => void)[] = [];
|
||||
private dispatcher?: Agent;
|
||||
|
||||
constructor(private readonly config: AppConfigService) {}
|
||||
|
||||
/** Hay URL, usuario y base: sin ellos el módulo responde 503 en vez de fallar al arrancar. */
|
||||
get configured(): boolean {
|
||||
return !!(this.config.get('APIGATEWAY_URL') && this.config.get('APIGATEWAY_USER') && this.config.get('APIGATEWAY_PASSWORD') && this.config.get('APIGATEWAY_COMPANYDB'));
|
||||
}
|
||||
|
||||
private agent(): Agent {
|
||||
if (!this.dispatcher) {
|
||||
const caFile = this.config.get('APIGATEWAY_CA_FILE');
|
||||
this.dispatcher = new Agent({
|
||||
connections: this.config.get('APIGATEWAY_MAX_CONCURRENCY'),
|
||||
connect: { ca: caFile ? [...rootCertificates, readFileSync(resolve(caFile), 'utf8')] : undefined, rejectUnauthorized: this.config.get('APIGATEWAY_VERIFY_SSL') },
|
||||
});
|
||||
}
|
||||
return this.dispatcher;
|
||||
}
|
||||
|
||||
/** Espera un turno de la cola y lo suelta al terminar. */
|
||||
private async turn<T>(work: () => Promise<T>): Promise<T> {
|
||||
if (this.active >= this.config.get('APIGATEWAY_MAX_CONCURRENCY')) await new Promise<void>((go) => this.waiting.push(go));
|
||||
this.active++;
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
this.active--;
|
||||
this.waiting.shift()?.();
|
||||
}
|
||||
}
|
||||
|
||||
async call(method: 'GET' | 'POST', path: string, opts: { query?: Record<string, string>; body?: unknown } = {}): Promise<GatewayResponse> {
|
||||
if (!this.configured) throw new ServiceUnavailableException('El API Gateway no está configurado (APIGATEWAY_URL, usuario y base en el .env)');
|
||||
return this.turn(async () => {
|
||||
let session = await this.getSession();
|
||||
let res = await this.send(session, method, path, opts);
|
||||
if (res.status === 401) {
|
||||
this.logger.warn(`Sesión del API Gateway expirada (401) en ${path}; reautenticando`);
|
||||
session = await this.renew(session);
|
||||
res = await this.send(session, method, path, opts);
|
||||
}
|
||||
if (res.status >= 400) throw new HttpException(`Error del API Gateway (HTTP ${res.status}): ${res.text.slice(0, 300)}`, res.status >= 500 ? 502 : res.status);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
private getSession(): Promise<Session> {
|
||||
return this.session ? Promise.resolve(this.session) : this.login();
|
||||
}
|
||||
|
||||
private renew(stale: Session): Promise<Session> {
|
||||
if (this.session && this.session !== stale) return Promise.resolve(this.session);
|
||||
this.session = undefined;
|
||||
return this.login();
|
||||
}
|
||||
|
||||
private login(): Promise<Session> {
|
||||
this.loginInFlight ??= this.doLogin().finally(() => {
|
||||
this.loginInFlight = undefined;
|
||||
});
|
||||
return this.loginInFlight;
|
||||
}
|
||||
|
||||
private async doLogin(): Promise<Session> {
|
||||
const timeout = this.config.get('APIGATEWAY_TIMEOUT') * 1000;
|
||||
this.logger.log('Iniciando sesión en el API Gateway');
|
||||
try {
|
||||
const res = await request(`${this.config.get('APIGATEWAY_URL')}/login`, {
|
||||
method: 'POST',
|
||||
dispatcher: this.agent(),
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
CompanyDB: this.config.get('APIGATEWAY_COMPANYDB'),
|
||||
UserName: this.config.get('APIGATEWAY_USER'),
|
||||
Password: this.config.get('APIGATEWAY_PASSWORD'),
|
||||
...(this.config.get('APIGATEWAY_DBINSTANCE') ? { DBInstance: this.config.get('APIGATEWAY_DBINSTANCE') } : {}),
|
||||
}),
|
||||
headersTimeout: timeout,
|
||||
bodyTimeout: timeout,
|
||||
});
|
||||
await res.body.dump();
|
||||
if (res.statusCode !== 200) {
|
||||
// un problema de configuración del servidor, no de la sesión del usuario web: 502, nunca 401
|
||||
this.logger.error(`Login del API Gateway rechazado (HTTP ${res.statusCode})`);
|
||||
throw new BadGatewayException('No se pudo iniciar sesión en el API Gateway con el usuario de servicio');
|
||||
}
|
||||
const raw = res.headers['set-cookie'];
|
||||
const cookie = (Array.isArray(raw) ? raw : raw ? [raw] : []).map((c) => c.split(';')[0].trim()).filter(Boolean).join('; ');
|
||||
this.session = { cookie };
|
||||
this.logger.log('Sesión del API Gateway establecida');
|
||||
return this.session;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
throw this.describe(err);
|
||||
}
|
||||
}
|
||||
|
||||
private async send(session: Session, method: 'GET' | 'POST', path: string, opts: { query?: Record<string, string>; body?: unknown }): Promise<GatewayResponse> {
|
||||
const timeout = this.config.get('APIGATEWAY_TIMEOUT') * 1000;
|
||||
const query = opts.query ? `?${new URLSearchParams(opts.query).toString()}` : '';
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await request(`${this.config.get('APIGATEWAY_URL')}/${path}${query}`, {
|
||||
method,
|
||||
dispatcher: this.agent(),
|
||||
headers: { 'content-type': 'application/json', accept: '*/*', ...(session.cookie ? { cookie: session.cookie } : {}) },
|
||||
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
||||
headersTimeout: timeout,
|
||||
bodyTimeout: timeout,
|
||||
});
|
||||
const text = await res.body.text();
|
||||
this.logger.debug(`${method} ${path} -> ${res.statusCode} (${Date.now() - started} ms)`);
|
||||
return { status: res.statusCode, text };
|
||||
} catch (err) {
|
||||
throw this.describe(err);
|
||||
}
|
||||
}
|
||||
|
||||
private describe(err: unknown): HttpException {
|
||||
const code = (err as { code?: string }).code ?? '';
|
||||
if (['UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'ETIMEDOUT'].includes(code)) return new GatewayTimeoutException('Tiempo de espera agotado con el API Gateway');
|
||||
if (code.includes('CERT') || code.includes('SELF_SIGNED') || code.includes('UNABLE_TO_VERIFY')) return new BadGatewayException(`No se pudo validar el certificado del API Gateway (${code}). Configure APIGATEWAY_CA_FILE.`);
|
||||
return new BadGatewayException('No se pudo conectar con el API Gateway');
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.dispatcher?.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/** Un reporte del API Gateway matriculado: el que los usuarios ven en la lista para ejecutar. */
|
||||
@Entity('gateway_reports')
|
||||
export class GatewayReportEntity {
|
||||
/** Código del reporte en el API Gateway (`RCRI0001`). */
|
||||
@PrimaryColumn({ type: 'varchar' })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
category: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled: boolean;
|
||||
|
||||
/** JSON con la lista de `GatewayParam`. */
|
||||
@Column({ type: 'text' })
|
||||
params: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
updatedBy: string | null;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Req, StreamableFile, UseGuards } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import type { AuthenticatedRequest } from '../auth/auth.types.js';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe.js';
|
||||
import { AdminGuard } from '../permissions/admin.guard.js';
|
||||
import { GatewayService } from './gateway.service.js';
|
||||
|
||||
const codeSchema = z.string().regex(/^[A-Za-z0-9_.-]{1,60}$/, 'Código de reporte inválido');
|
||||
const text = (max: number) => z.string().trim().max(max);
|
||||
|
||||
const paramSchema = z.object({
|
||||
name: text(200).min(1),
|
||||
type: text(60).min(1),
|
||||
label: text(120).min(1),
|
||||
kind: z.enum(['text', 'number', 'date', 'select', 'lookup']),
|
||||
required: z.boolean(),
|
||||
range: z.boolean().optional(),
|
||||
multiple: z.boolean().optional(),
|
||||
defaultValue: text(200).optional(),
|
||||
placeholder: text(120).optional(),
|
||||
options: z.array(z.object({ value: text(200), label: text(200) })).max(100).optional(),
|
||||
lookup: text(60).optional(),
|
||||
});
|
||||
|
||||
const reportSchema = z.object({
|
||||
title: text(160).min(1),
|
||||
category: text(60).min(1),
|
||||
description: text(500).optional(),
|
||||
enabled: z.boolean(),
|
||||
params: z.array(paramSchema).max(60),
|
||||
});
|
||||
type ReportDto = z.infer<typeof reportSchema>;
|
||||
|
||||
const exportSchema = z.object({ values: z.record(z.string(), z.union([z.string().max(500), z.array(z.string().max(500)).max(200)])) });
|
||||
type ExportDto = z.infer<typeof exportSchema>;
|
||||
|
||||
/** Reportes del API Gateway: matrícula (administradores) y ejecución (permiso de menú «Reportes»). */
|
||||
@Controller('gateway')
|
||||
export class GatewayController {
|
||||
constructor(private readonly gateway: GatewayService) {}
|
||||
|
||||
// --- matrícula (solo acceso total) ----------------------------------------------------------
|
||||
|
||||
@Get('authorized')
|
||||
@UseGuards(AdminGuard)
|
||||
authorized() {
|
||||
return this.gateway.authorized();
|
||||
}
|
||||
|
||||
@Get('authorized/:code/parameters')
|
||||
@UseGuards(AdminGuard)
|
||||
parameters(@Param('code', new ZodValidationPipe(codeSchema)) code: string) {
|
||||
return this.gateway.parameters(code);
|
||||
}
|
||||
|
||||
@Get('registry')
|
||||
@UseGuards(AdminGuard)
|
||||
registry() {
|
||||
return this.gateway.registry();
|
||||
}
|
||||
|
||||
@Put('registry/:code')
|
||||
@UseGuards(AdminGuard)
|
||||
save(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('code', new ZodValidationPipe(codeSchema)) code: string,
|
||||
@Body(new ZodValidationPipe(reportSchema)) body: ReportDto,
|
||||
) {
|
||||
return this.gateway.save(code, body, req.user, req.ip ?? '');
|
||||
}
|
||||
|
||||
@Delete('registry/:code')
|
||||
@HttpCode(204)
|
||||
@UseGuards(AdminGuard)
|
||||
async remove(@Req() req: AuthenticatedRequest, @Param('code', new ZodValidationPipe(codeSchema)) code: string) {
|
||||
await this.gateway.remove(code, req.user, req.ip ?? '');
|
||||
}
|
||||
|
||||
// --- ejecución -------------------------------------------------------------------------------
|
||||
|
||||
@Get('reports')
|
||||
reports(@Req() req: AuthenticatedRequest) {
|
||||
return this.gateway.enabled(req.user);
|
||||
}
|
||||
|
||||
@Post('reports/:code/export')
|
||||
@HttpCode(200)
|
||||
async export(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('code', new ZodValidationPipe(codeSchema)) code: string,
|
||||
@Body(new ZodValidationPipe(exportSchema)) body: ExportDto,
|
||||
) {
|
||||
const pdf = await this.gateway.export(req.user, code, body.values, req.ip ?? '');
|
||||
return new StreamableFile(pdf, { type: 'application/pdf', disposition: `inline; filename="${code}.pdf"` });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuditModule } from '../audit/audit.module.js';
|
||||
import { PermissionsModule } from '../permissions/permissions.module.js';
|
||||
import { GatewayClientService } from './gateway-client.service.js';
|
||||
import { GatewayController } from './gateway.controller.js';
|
||||
import { GatewayReportEntity } from './gateway-report.entity.js';
|
||||
import { GatewayService } from './gateway.service.js';
|
||||
|
||||
/** Reportes Crystal por el API Gateway de SAP B1: su propia sesión y su propia cola. */
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([GatewayReportEntity]), AuditModule, PermissionsModule],
|
||||
controllers: [GatewayController],
|
||||
providers: [GatewayClientService, GatewayService],
|
||||
})
|
||||
export class GatewayModule {}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { type GatewayParam } from '@basic-erp/sap-types';
|
||||
import { vi } from 'vitest';
|
||||
import type { AuditService } from '../audit/audit.service.js';
|
||||
import type { AuthUser } from '../auth/auth.types.js';
|
||||
import type { PermissionsService } from '../permissions/permissions.service.js';
|
||||
import type { GatewayClientService } from './gateway-client.service.js';
|
||||
import type { GatewayReportEntity } from './gateway-report.entity.js';
|
||||
import { GatewayService, decodePdf, gatewayMessage } from './gateway.service.js';
|
||||
|
||||
const user: AuthUser = { webUser: 'ana', empId: 7, firstName: 'Ana', lastName: 'P' };
|
||||
const PDF = Buffer.from('%PDF-1.4 contenido');
|
||||
const params: GatewayParam[] = [
|
||||
{ name: 'Dockey@', type: 'xsd:decimal', label: 'Documento', kind: 'lookup', required: true, lookup: 'doc:DeliveryNotes' },
|
||||
{ name: 'CANTIDAD', type: 'xsd:decimal', label: 'Cantidad', kind: 'number', required: false },
|
||||
];
|
||||
|
||||
describe('decodePdf', () => {
|
||||
it('acepta el base64 tal cual y entre comillas', () => {
|
||||
expect(decodePdf(PDF.toString('base64')).equals(PDF)).toBe(true);
|
||||
expect(decodePdf(JSON.stringify(PDF.toString('base64'))).equals(PDF)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gatewayMessage', () => {
|
||||
it('muestra el texto de error del gateway o el «(---)» de un reporte sin resultado', () => {
|
||||
expect(gatewayMessage('Error code name: Unparseable date: "31/01/2026"')).toContain('Unparseable date');
|
||||
expect(gatewayMessage('(---)')).toBe('(---)');
|
||||
expect(gatewayMessage(Buffer.from('mensaje en base64').toString('base64'))).toBe('mensaje en base64');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GatewayService', () => {
|
||||
let rows: Map<string, GatewayReportEntity>;
|
||||
let call: ReturnType<typeof vi.fn>;
|
||||
let record: ReturnType<typeof vi.fn>;
|
||||
let paths: string[];
|
||||
let service: GatewayService;
|
||||
|
||||
beforeEach(() => {
|
||||
rows = new Map();
|
||||
call = vi.fn(async (_m: string, path: string) => {
|
||||
if (path.endsWith('LoadAuthorizedCRList')) return { status: 200, text: JSON.stringify({ resultSet: [{ code: 'DLN20015', name: 'Etiquetas', root_name: '#1#4', root_guid: '44' }] }) };
|
||||
if (path.endsWith('LoadCR')) return { status: 200, text: JSON.stringify({ error: false, resultSet: [{ name: 'Dockey@', type: 'xsd:decimal', description: 'Documento', isOptionalPrompt: 'false' }] }) };
|
||||
return { status: 200, text: PDF.toString('base64') };
|
||||
});
|
||||
record = vi.fn(async () => undefined);
|
||||
paths = ['*'];
|
||||
const repo = {
|
||||
find: async (opts?: { where?: { enabled?: boolean } }) => [...rows.values()].filter((r) => opts?.where?.enabled === undefined || r.enabled === opts.where.enabled),
|
||||
findOneBy: async ({ code }: { code: string }) => rows.get(code) ?? null,
|
||||
save: async (e: GatewayReportEntity) => void rows.set(e.code, e),
|
||||
remove: async (e: GatewayReportEntity) => void rows.delete(e.code),
|
||||
};
|
||||
service = new GatewayService(
|
||||
{ call } as unknown as GatewayClientService,
|
||||
repo as never,
|
||||
{ getPaths: async () => paths } as unknown as PermissionsService,
|
||||
{ record } as unknown as AuditService,
|
||||
);
|
||||
});
|
||||
|
||||
const register = () => service.save('DLN20015', { title: 'Etiquetas', category: 'inventario', enabled: true, params }, user, '10.0.0.5');
|
||||
|
||||
it('lista lo que el gateway autoriza y arma los parámetros de un reporte desde LoadCR', async () => {
|
||||
expect((await service.authorized()).map((r) => r.code)).toEqual(['DLN20015']);
|
||||
expect(await service.parameters('DLN20015')).toEqual([{ name: 'Dockey@', type: 'xsd:decimal', label: 'Documento', kind: 'number', required: true }]);
|
||||
});
|
||||
|
||||
it('solo se matricula un reporte que el gateway autoriza, y queda registrado en la auditoría', async () => {
|
||||
await expect(service.save('NOEXISTE', { title: 'x', category: 'otros', enabled: true, params: [] }, user, '10.0.0.5')).rejects.toThrow(BadRequestException);
|
||||
await register();
|
||||
expect((await service.registry())[0]).toMatchObject({ code: 'DLN20015', category: 'inventario', params });
|
||||
expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'report_register', resource: 'DLN20015' }));
|
||||
// volver a guardar uno ya matriculado no consulta al gateway
|
||||
call.mockClear();
|
||||
await register();
|
||||
expect(call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('desmatricular quita el reporte y avisa si no estaba', async () => {
|
||||
await register();
|
||||
await service.remove('DLN20015', user, '10.0.0.5');
|
||||
expect(await service.registry()).toEqual([]);
|
||||
await expect(service.remove('DLN20015', user, '10.0.0.5')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('ejecutar exige el permiso de menú y que el reporte esté matriculado y habilitado', async () => {
|
||||
await register();
|
||||
paths = ['/ventas/*'];
|
||||
await expect(service.export(user, 'DLN20015', { 'Dockey@': '1' }, '10.0.0.5')).rejects.toThrow(ForbiddenException);
|
||||
paths = ['/reportes/ejecutar'];
|
||||
await expect(service.export(user, 'OTRO', {}, '10.0.0.5')).rejects.toThrow(NotFoundException);
|
||||
await service.save('DLN20015', { title: 'Etiquetas', category: 'inventario', enabled: false, params }, user, '10.0.0.5');
|
||||
await expect(service.export(user, 'DLN20015', { 'Dockey@': '1' }, '10.0.0.5')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('genera el PDF con el cuerpo que arman los parámetros matriculados y audita solo sus nombres', async () => {
|
||||
await register();
|
||||
const pdf = await service.export(user, 'DLN20015', { 'Dockey@': '137138', CANTIDAD: '5', Intruso: 'x' }, '10.0.0.5');
|
||||
expect(pdf.equals(PDF)).toBe(true);
|
||||
expect(call).toHaveBeenLastCalledWith('POST', 'rs/v1/ExportPDFData', {
|
||||
query: { DocCode: 'DLN20015' },
|
||||
body: [
|
||||
{ name: 'Dockey@', type: 'xsd:decimal', value: [['137138']] },
|
||||
{ name: 'CANTIDAD', type: 'xsd:decimal', value: [['5']] },
|
||||
],
|
||||
});
|
||||
expect(record).toHaveBeenLastCalledWith(expect.objectContaining({ action: 'report_export', detail: { params: ['Dockey@', 'CANTIDAD'] } }));
|
||||
expect(JSON.stringify(record.mock.calls)).not.toContain('137138');
|
||||
});
|
||||
|
||||
it('un parámetro obligatorio sin valor no llega al gateway', async () => {
|
||||
await register();
|
||||
call.mockClear();
|
||||
await expect(service.export(user, 'DLN20015', { CANTIDAD: '5' }, '10.0.0.5')).rejects.toThrow(/Documento/);
|
||||
expect(call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('si el gateway no devuelve un PDF lo rechaza y lo deja en la auditoría', async () => {
|
||||
await register();
|
||||
call.mockResolvedValueOnce({ status: 200, text: Buffer.from('no es pdf').toString('base64') });
|
||||
await expect(service.export(user, 'DLN20015', { 'Dockey@': '1' }, '10.0.0.5')).rejects.toThrow(/no devolvió un PDF: no es pdf/);
|
||||
expect(record).toHaveBeenLastCalledWith(expect.objectContaining({ action: 'report_export', outcome: 'error' }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { type AuthorizedReport, type GatewayParam, type GatewayReport, type GatewayValues, exportBody, isPathAllowed, paramsFromLoadCr } from '@basic-erp/sap-types';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AuditService } from '../audit/audit.service.js';
|
||||
import type { AuthUser } from '../auth/auth.types.js';
|
||||
import { PermissionsService } from '../permissions/permissions.service.js';
|
||||
import { GatewayClientService } from './gateway-client.service.js';
|
||||
import { GatewayReportEntity } from './gateway-report.entity.js';
|
||||
|
||||
/** Ruta de menú que permite ejecutar los reportes matriculados. */
|
||||
export const EXECUTE_PATH = '/reportes/ejecutar';
|
||||
|
||||
const toReport = (e: GatewayReportEntity): GatewayReport => ({
|
||||
code: e.code,
|
||||
title: e.title,
|
||||
category: e.category,
|
||||
...(e.description ? { description: e.description } : {}),
|
||||
enabled: e.enabled,
|
||||
params: JSON.parse(e.params) as GatewayParam[],
|
||||
});
|
||||
|
||||
/** El API Gateway devuelve el PDF en base64, a veces entre comillas (un texto JSON). */
|
||||
export function decodePdf(text: string): Buffer {
|
||||
let body = text.trim();
|
||||
if (body.startsWith('"')) {
|
||||
try {
|
||||
body = JSON.parse(body) as string;
|
||||
} catch {
|
||||
/* se intenta tal cual */
|
||||
}
|
||||
}
|
||||
return Buffer.from(body, 'base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lo que dice el API Gateway cuando no devuelve un PDF: un mensaje de error en texto («Unparseable date…») o `(---)`
|
||||
* cuando el reporte no produjo nada.
|
||||
*/
|
||||
export function gatewayMessage(text: string): string {
|
||||
const raw = text.trim().replace(/^"|"$/g, '');
|
||||
const decoded = Buffer.from(raw, 'base64').toString('utf8');
|
||||
const readable = decoded.length > 0 && !decoded.includes('\ufffd') && /^[\x09\x0a\x0d\x20-\x7e\u00a0-\uffff]+$/.test(decoded) ? decoded : raw;
|
||||
return readable.slice(0, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reportes Crystal del API Gateway: lo que el gateway autoriza, la matrícula (qué reportes se usan, con qué
|
||||
* parámetros y cómo se piden) y su ejecución. Matricular es de administradores; ejecutar, de quien tenga el permiso de menú.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GatewayService {
|
||||
constructor(
|
||||
private readonly client: GatewayClientService,
|
||||
@InjectRepository(GatewayReportEntity) private readonly repo: Repository<GatewayReportEntity>,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
// --- lo que el API Gateway autoriza ---------------------------------------------------------
|
||||
|
||||
async authorized(): Promise<AuthorizedReport[]> {
|
||||
const res = await this.client.call('GET', 'rs/v1/LoadAuthorizedCRList');
|
||||
const body = JSON.parse(res.text) as { resultSet?: AuthorizedReport[] } | AuthorizedReport[];
|
||||
return Array.isArray(body) ? body : (body.resultSet ?? []);
|
||||
}
|
||||
|
||||
/** Los parámetros de un reporte como definiciones de matrícula (el administrador las ajusta). */
|
||||
async parameters(code: string): Promise<GatewayParam[]> {
|
||||
const res = await this.client.call('GET', 'rs/v1/LoadCR', { query: { DocCode: code } });
|
||||
const body = JSON.parse(res.text) as { error?: boolean; resultSet?: Parameters<typeof paramsFromLoadCr>[0] };
|
||||
if (body.error) throw new BadRequestException(`El API Gateway no pudo cargar el reporte ${code}`);
|
||||
return paramsFromLoadCr(body.resultSet ?? []);
|
||||
}
|
||||
|
||||
// --- matrícula -------------------------------------------------------------------------------
|
||||
|
||||
async registry(): Promise<GatewayReport[]> {
|
||||
return (await this.repo.find({ order: { category: 'ASC', title: 'ASC' } })).map(toReport);
|
||||
}
|
||||
|
||||
/** Los reportes habilitados, para la lista de ejecución. */
|
||||
async enabled(user: AuthUser): Promise<GatewayReport[]> {
|
||||
await this.assertCanExecute(user);
|
||||
return (await this.repo.find({ where: { enabled: true }, order: { category: 'ASC', title: 'ASC' } })).map(toReport);
|
||||
}
|
||||
|
||||
async save(code: string, report: Omit<GatewayReport, 'code'>, user: AuthUser, ip: string): Promise<GatewayReport> {
|
||||
const exists = await this.repo.findOneBy({ code });
|
||||
// un reporte nuevo debe ser uno de los que el API Gateway autoriza
|
||||
if (!exists && !(await this.authorized()).some((r) => r.code === code)) throw new BadRequestException(`El reporte ${code} no está autorizado en el API Gateway`);
|
||||
await this.repo.save({
|
||||
code,
|
||||
title: report.title,
|
||||
category: report.category,
|
||||
description: report.description ?? null,
|
||||
enabled: report.enabled,
|
||||
params: JSON.stringify(report.params),
|
||||
updatedBy: user.webUser,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await this.audit.record({ action: 'report_register', webUser: user.webUser, ip, resource: code, detail: { params: report.params.map((p) => p.name), enabled: report.enabled } });
|
||||
return { code, ...report };
|
||||
}
|
||||
|
||||
async remove(code: string, user: AuthUser, ip: string): Promise<void> {
|
||||
const found = await this.repo.findOneBy({ code });
|
||||
if (!found) throw new NotFoundException(`El reporte ${code} no está matriculado`);
|
||||
await this.repo.remove(found);
|
||||
await this.audit.record({ action: 'report_unregister', webUser: user.webUser, ip, resource: code });
|
||||
}
|
||||
|
||||
// --- ejecución -------------------------------------------------------------------------------
|
||||
|
||||
private async assertCanExecute(user: AuthUser): Promise<void> {
|
||||
if (!isPathAllowed(await this.permissions.getPaths(user.webUser), EXECUTE_PATH)) throw new ForbiddenException('No tiene permisos para ejecutar reportes');
|
||||
}
|
||||
|
||||
/** Genera el PDF de un reporte matriculado con los valores que escribió el usuario. */
|
||||
async export(user: AuthUser, code: string, values: GatewayValues, ip: string): Promise<Buffer> {
|
||||
await this.assertCanExecute(user);
|
||||
const found = await this.repo.findOneBy({ code });
|
||||
if (!found || !found.enabled) throw new NotFoundException(`El reporte ${code} no está disponible`);
|
||||
const { body, missing } = exportBody(toReport(found).params, values);
|
||||
if (missing.length) throw new BadRequestException(`Falta indicar: ${missing.join(', ')}`);
|
||||
// solo los nombres de los parámetros: los valores pueden ser datos de clientes
|
||||
const detail = { params: body.map((b) => b.name) };
|
||||
try {
|
||||
const res = await this.client.call('POST', 'rs/v1/ExportPDFData', { query: { DocCode: code }, body });
|
||||
const pdf = decodePdf(res.text);
|
||||
if (pdf.subarray(0, 4).toString('latin1') !== '%PDF') throw new BadRequestException(`El reporte no devolvió un PDF: ${gatewayMessage(res.text)}`);
|
||||
await this.audit.record({ action: 'report_export', webUser: user.webUser, ip, resource: code, detail });
|
||||
return pdf;
|
||||
} catch (e) {
|
||||
await this.audit.record({ action: 'report_export', webUser: user.webUser, ip, resource: code, outcome: 'error', detail: { ...detail, error: e instanceof Error ? e.message.slice(0, 300) : String(e) } });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,10 @@ export type AuditAction =
|
||||
| 'sl_action'
|
||||
| 'sl_denied'
|
||||
| 'permissions_set'
|
||||
| 'permissions_remove';
|
||||
| 'permissions_remove'
|
||||
| 'report_export'
|
||||
| 'report_register'
|
||||
| 'report_unregister';
|
||||
|
||||
export type AuditOutcome = 'ok' | 'error' | 'denied';
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { type GatewayParam, exportBody, paramsFromLoadCr } from './gateway.js';
|
||||
|
||||
describe('paramsFromLoadCr', () => {
|
||||
it('convierte lo que describe LoadCR en definiciones de matrícula', () => {
|
||||
const params = paramsFromLoadCr([
|
||||
{ name: 'PWZRunDate', type: 'xsd:date', description: 'Fecha de ejecución de pago:', isOptionalPrompt: 'true', valueRangeKind: 'Range', allowMultiValue: 'false', defaultValues: [] },
|
||||
{ name: 'Dockey@', type: 'xsd:decimal', description: 'Especificar Dockey@:', isOptionalPrompt: 'false', valueRangeKind: 'Discrete', allowMultiValue: 'false' },
|
||||
{ name: 'BP@From OCRD', type: 'xsd:string', description: 'Socios de negocios', isOptionalPrompt: 'true', allowMultiValue: 'true', defaultValues: ['C1'] },
|
||||
{ type: 'xsd:string' },
|
||||
]);
|
||||
expect(params).toEqual([
|
||||
{ name: 'PWZRunDate', type: 'xsd:date', label: 'Fecha de ejecución de pago', kind: 'date', required: false, range: true },
|
||||
{ name: 'Dockey@', type: 'xsd:decimal', label: 'Dockey@', kind: 'number', required: true },
|
||||
{ name: 'BP@From OCRD', type: 'xsd:string', label: 'Socios de negocios', kind: 'text', required: false, multiple: true, defaultValue: 'C1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exportBody', () => {
|
||||
const p = (over: Partial<GatewayParam>): GatewayParam => ({ name: 'P', type: 'xsd:string', label: 'Parámetro', kind: 'text', required: false, ...over });
|
||||
|
||||
it('un valor va como lista de listas, con el nombre y el tipo del parámetro', () => {
|
||||
expect(exportBody([p({ name: 'Dockey@', type: 'xsd:decimal', required: true })], { 'Dockey@': ' 137138 ' }).body).toEqual([{ name: 'Dockey@', type: 'xsd:decimal', value: [['137138']] }]);
|
||||
});
|
||||
|
||||
it('varios valores van uno por lista y un rango, desde y hasta en una sola', () => {
|
||||
expect(exportBody([p({ multiple: true })], { P: ['A', ' ', 'B'] }).body[0].value).toEqual([['A'], ['B']]);
|
||||
expect(exportBody([p({ range: true })], { P: ['2026-01-01', '2026-01-31'] }).body[0].value).toEqual([['2026-01-01', '2026-01-31']]);
|
||||
expect(exportBody([p({ range: true })], { P: ['2026-01-01', ''] }).body[0].value).toEqual([['2026-01-01', '2026-01-01']]); // sin «hasta», el mismo día
|
||||
});
|
||||
|
||||
it('un opcional vacío no se envía y un obligatorio vacío se reporta', () => {
|
||||
const r = exportBody([p({ name: 'A', label: 'Cliente', required: true }), p({ name: 'B' })], { A: '', B: '' });
|
||||
expect(r.body).toEqual([]);
|
||||
expect(r.missing).toEqual(['Cliente']);
|
||||
});
|
||||
|
||||
it('ignora valores de parámetros que no están matriculados', () => {
|
||||
expect(exportBody([p({ name: 'A' })], { A: '1', Otro: '2' }).body.map((b) => b.name)).toEqual(['A']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Reportes del API Gateway de SAP B1 (Crystal Reports): cómo se matriculan, qué parámetros piden y cómo se arma lo
|
||||
* que se envía a `ExportPDFData`. Lo comparten la API y la web.
|
||||
*/
|
||||
|
||||
export type GatewayParamKind = 'text' | 'number' | 'date' | 'select' | 'lookup';
|
||||
|
||||
/** Un parámetro de un reporte matriculado. */
|
||||
export interface GatewayParam {
|
||||
/** Nombre del parámetro en Crystal, con su sufijo (`Item@Select * from OITM`, `Dockey@`). */
|
||||
name: string;
|
||||
/** Tipo de Crystal (`xsd:string`, `xsd:decimal`, `xsd:date`…); se envía tal cual. */
|
||||
type: string;
|
||||
label: string;
|
||||
/** Cómo se pide al usuario: texto, número, fecha, lista fija o ventana de selección. */
|
||||
kind: GatewayParamKind;
|
||||
required: boolean;
|
||||
/** Se piden dos valores, desde y hasta. */
|
||||
range?: boolean;
|
||||
/** Se admiten varios valores. */
|
||||
multiple?: boolean;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
/** Opciones de una lista fija (`kind = select`). */
|
||||
options?: { value: string; label: string }[];
|
||||
/** Ventana de selección (`kind = lookup`): identificador de su catálogo en la web (`socios`, `articulos`, `doc:Invoices`…). */
|
||||
lookup?: string;
|
||||
}
|
||||
|
||||
/** Un reporte matriculado: el que los usuarios ven en la lista para ejecutar. */
|
||||
export interface GatewayReport {
|
||||
/** Código del reporte en el API Gateway (`RCRI0001`, `DLN20015`). */
|
||||
code: string;
|
||||
title: string;
|
||||
category: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
params: GatewayParam[];
|
||||
}
|
||||
|
||||
/** Un reporte que el API Gateway autoriza (`LoadAuthorizedCRList`). */
|
||||
export interface AuthorizedReport {
|
||||
code: string;
|
||||
name: string;
|
||||
/** Carpeta de Crystal (`#1000004#4`). */
|
||||
root_name: string;
|
||||
root_guid: string;
|
||||
}
|
||||
|
||||
/** Lo que el usuario escribió: un texto, o una lista si el parámetro es de rango o admite varios valores. */
|
||||
export type GatewayValues = Readonly<Record<string, string | readonly string[] | undefined>>;
|
||||
|
||||
/** Un parámetro tal como lo describe `LoadCR`. */
|
||||
interface CrystalParameter {
|
||||
name?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
isOptionalPrompt?: string;
|
||||
valueRangeKind?: string;
|
||||
allowMultiValue?: string;
|
||||
defaultValues?: unknown[];
|
||||
}
|
||||
|
||||
const NUMERIC = /(decimal|int|double|float|number|currency)/i;
|
||||
|
||||
/** Los parámetros de un reporte (respuesta de `LoadCR`) como definiciones de matrícula, para completarlas y ajustarlas. */
|
||||
export function paramsFromLoadCr(resultSet: readonly CrystalParameter[]): GatewayParam[] {
|
||||
return resultSet
|
||||
.filter((p): p is CrystalParameter & { name: string } => typeof p.name === 'string' && p.name !== '')
|
||||
.map((p) => {
|
||||
const type = p.type ?? 'xsd:string';
|
||||
const first = p.defaultValues?.[0];
|
||||
return {
|
||||
name: p.name,
|
||||
type,
|
||||
label: (p.description ?? p.name).replace(/[:\s]+$/, '').replace(/^Especificar\s+/i, '') || p.name,
|
||||
kind: /date/i.test(type) ? 'date' : NUMERIC.test(type) ? 'number' : 'text',
|
||||
required: p.isOptionalPrompt !== 'true',
|
||||
...(p.valueRangeKind === 'Range' ? { range: true } : {}),
|
||||
...(p.allowMultiValue === 'true' ? { multiple: true } : {}),
|
||||
...(first !== undefined && first !== null && first !== '' ? { defaultValue: String(first) } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const isBlank = (v: string | readonly string[] | undefined): boolean => v === undefined || (typeof v === 'string' ? v.trim() === '' : v.every((x) => x.trim() === ''));
|
||||
|
||||
/**
|
||||
* Cuerpo de `ExportPDFData`: por cada parámetro matriculado `{ name, type, value }`, con el valor como lista de listas
|
||||
* (`[["137138"]]` un valor, `[["a"],["b"]]` varios, `[["desde","hasta"]]` un rango). Un parámetro opcional sin valor no se
|
||||
* envía. Devuelve los parámetros obligatorios que faltan por separado.
|
||||
*/
|
||||
export function exportBody(params: readonly GatewayParam[], values: GatewayValues): { body: { name: string; type: string; value: string[][] }[]; missing: string[] } {
|
||||
const body: { name: string; type: string; value: string[][] }[] = [];
|
||||
const missing: string[] = [];
|
||||
for (const p of params) {
|
||||
const raw = values[p.name];
|
||||
if (isBlank(raw)) {
|
||||
if (p.required) missing.push(p.label);
|
||||
continue;
|
||||
}
|
||||
const list = (typeof raw === 'string' ? [raw] : [...(raw as readonly string[])]).map((x) => x.trim());
|
||||
let value: string[][];
|
||||
if (p.range) value = [[list[0] ?? '', list[1] || list[0] || '']];
|
||||
else if (p.multiple) value = list.filter((x) => x !== '').map((x) => [x]);
|
||||
else value = [[list[0]]];
|
||||
body.push({ name: p.name, type: p.type, value });
|
||||
}
|
||||
return { body, missing };
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export * from './company.js';
|
||||
export * from './permissions.js';
|
||||
export * from './sl.js';
|
||||
export * from './menu.js';
|
||||
export * from './gateway.js';
|
||||
Reference in new issue
Block a user