feat(api): módulo de consultas de solo lectura a la base de SAP (HANA con hdb): consultas registradas con parámetros con nombre, cola, auditoría y extensión por cliente
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9118985d96
commit
3e27f370dc
21 files changed
+770
-2
No files matched your search
@@ -0,0 +1,82 @@
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user