import { BadGatewayException, ForbiddenException, Inject, Injectable, Logger, NotFoundException, ServiceUnavailableException, NotImplementedException } from '@nestjs/common'; import { isPathAllowed } from '@basic-erp/sap-types'; import { AuditService } from '../audit/audit.service.js'; import type { AuthUser } from '../auth/auth.types.js'; import { AppConfigService } from '../config/app-config.service.js'; import { PermissionsService } from '../permissions/permissions.service.js'; import { SAPDB_QUERIES } from './sapdb-queries.js'; import { bindNamed, parseParams } from './sapdb-sql.js'; import { SAPDB_ADAPTER, type SapDbAdapter, type SapDbQuery, type SapDbResult } from './sapdb.types.js'; /** * Consultas de solo lectura a la base de datos de SAP B1. Solo se ejecutan las registradas (`sapdb-queries.ts` y la * extensión del cliente), con parámetros tipados y cola propia (`SAPDB_MAX_CONCURRENCY`): la base de SAP no es un * servicio para ráfagas. Quien consulta necesita el permiso de menú que declara la consulta. */ @Injectable() export class SapDbService { private readonly logger = new Logger(SapDbService.name); private active = 0; private readonly waiting: (() => void)[] = []; constructor( @Inject(SAPDB_ADAPTER) private readonly adapter: SapDbAdapter | null, private readonly config: AppConfigService, private readonly permissions: PermissionsService, private readonly audit: AuditService, ) {} get configured(): boolean { return this.adapter !== null; } /** Las consultas que el usuario puede ejecutar (nombre y título). */ async available(user: AuthUser): Promise<{ name: string; title: string }[]> { const paths = await this.permissions.getPaths(user.webUser); return [...SAPDB_QUERIES.values()].filter((q) => this.allowed(paths, q)).map((q) => ({ name: q.name, title: q.title })); } private allowed(paths: readonly string[], q: SapDbQuery): boolean { if (q.menu === null) return true; return [q.menu].flat().some((m) => isPathAllowed([...paths], m)); } private async turn(work: () => Promise): Promise { if (this.active >= this.config.get('SAPDB_MAX_CONCURRENCY')) await new Promise((go) => this.waiting.push(go)); this.active++; try { return await work(); } finally { this.active--; this.waiting.shift()?.(); } } async run(user: AuthUser, name: string, rawParams: Readonly>, ip: string): Promise { if (!this.adapter) throw new ServiceUnavailableException('La base de datos de SAP no está configurada (SAPDB_*)'); const query = SAPDB_QUERIES.get(name); if (!query) throw new NotFoundException(`No existe la consulta ${name}`); if (!this.allowed(await this.permissions.getPaths(user.webUser), query)) { await this.audit.record({ action: 'db_query', webUser: user.webUser, ip, resource: name, outcome: 'denied' }); throw new ForbiddenException('No tiene permisos para esta consulta'); } const sql = query.sql[this.config.get('SAPDB_ENGINE') ?? 'hana']; if (!sql) throw new NotImplementedException(`La consulta ${name} no está disponible para este motor de base de datos`); const values = parseParams(query, rawParams); const bound = bindNamed(sql, values); const cap = Math.min(query.maxRows ?? Infinity, this.config.get('SAPDB_MAX_ROWS')); // solo los nombres de los parámetros: los valores pueden ser datos de clientes const detail = { params: Object.keys(query.params) }; const started = Date.now(); try { const rows = await this.turn(() => this.adapter!.query(bound.sql, bound.args, { timeoutMs: this.config.get('SAPDB_TIMEOUT') * 1000 })); await this.audit.record({ action: 'db_query', webUser: user.webUser, ip, resource: name, detail: { ...detail, rows: rows.length, ms: Date.now() - started } }); return { rows: rows.slice(0, cap), truncated: rows.length > cap }; } catch (e) { const message = e instanceof Error ? e.message : String(e); this.logger.error(`Consulta ${name} falló: ${message}`); await this.audit.record({ action: 'db_query', webUser: user.webUser, ip, resource: name, outcome: 'error', detail: { ...detail, error: message.slice(0, 300) } }); throw new BadGatewayException(`No se pudo consultar la base de datos: ${message.slice(0, 200)}`); } } }