83 lines
4.3 KiB
TypeScript
83 lines
4.3 KiB
TypeScript
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<T>(work: () => Promise<T>): Promise<T> {
|
|
if (this.active >= this.config.get('SAPDB_MAX_CONCURRENCY')) await new Promise<void>((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<Record<string, unknown>>, ip: string): Promise<SapDbResult> {
|
|
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)}`);
|
|
}
|
|
}
|
|
}
|