Files
basic-erp/apps/api/src/sapdb/sapdb.service.ts
T

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)}`);
}
}
}