diff --git a/CLAUDE.md b/CLAUDE.md index 10b6e82..5b44cce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,6 +54,10 @@ Es un ERP, no una página de publicidad: **ligera y rápida**. Sin librerías de Cada menú abre primero una **lista de los últimos documentos** (barra de filtros + búsqueda por columna); desde ahí se crea uno nuevo (`/…/nuevo`, formulario vacío) o se abre uno existente (`/…/:docEntry`, ver/editar; los cerrados/cancelados en solo lectura). Las pantallas se definen por módulo en `apps/web/src/app/features/documents/` (17 documentos): `ventas/ventas.screens.ts`, `compras/compras.screens.ts` (+ `compras.layouts.ts`) e `inventario/inventario.screens.ts` (+ `inventario.layouts.ts`); los maestros (socios de negocio, artículos) irán en su propia carpeta. `screen-kit.ts` tiene el tipo `DocumentScreen` y las columnas y filtros comunes; `document-screens.ts` las reúne en `DOCUMENT_SCREENS`. Las columnas de línea estándar están en `features/documents/line-columns.ts`. **Tipo Artículo / Servicio** (`DocType`): los documentos de ventas y compras con socio llevan en la cabecera la lista «Tipo» (primera de la columna izquierda; no se cambia en uno guardado). Con «Servicio» las líneas pasan a `layout.service` (`serviceLineColumns` en `line-columns.ts`): Cuenta contable (obligatoria), Descripción, Impuesto y Total (se escribe, sin recalcular), dimensiones, proyecto y referencias al origen; sin artículo, cantidad, almacén, precio, lotes ni «Elegir artículos». Se guardan `AccountCode`, `ItemDescription`, `TaxCode`, `LineTotal` (`LinesSpec.service`); cambiar el tipo con líneas escritas pide permiso y las descarta; «Copiar de» trae el tipo y el importe abierto (`OpenAmount`) del origen y no mezcla tipos. Las extensiones de cliente de columnas de línea aplican a las líneas de artículos; las de servicio aún no son extensibles. **Indicador y folio juntos y en ese orden** (`IDENTITY_GROUP`, un `HeaderField` tipo `group` = varias celdas en una fila: Indicador con su lista, prefijo y número del folio) en facturas, boletas, reservas y notas de crédito de ventas y compras (`BILLING_DOCUMENT`) y en el asiento. Una empresa que los sustituye por campos de usuario arma su grupo con `fieldGroup(label, cells)` en `headerRight` de su extensión y oculta el del base con `hideHeaderFields: ['Indicator']` (oculta el grupo completo). Las **tres fechas** de la cabecera de todo documento de ventas y compras van juntas y en este orden, una debajo de la otra: contabilización, vencimiento y del documento (`TaxDate`), las tres con la fecha de hoy al crear. **Pestañas Contenido, Logística y Finanzas** en ventas y compras (`layout.tabs`, `tools/logistica.png` y `tools/finanzas.png`): Logística = destino y dirección de facturación (se eligen de las direcciones del socio, con su texto debajo), forma de envío, número de seguimiento y bloquear reclamaciones; Finanzas = entrada en el diario, cuenta asociada, bloqueo de pago, condiciones y forma de pago (de cobro en ventas, de pago en compras), indicador banco central, plazos, recálculo del vencimiento y proyecto. Al elegir el socio se cargan sus contactos y direcciones (`BusinessPartners(cod)` con `BPAddresses` y `ContactEmployees`) y se prellenan las direcciones, condiciones, forma de pago y cuenta asociada que trae (solo si el campo está vacío). La persona de contacto va bajo el nombre del socio. Las transferencias de stock y las solicitudes de traslado también llevan socio, contacto, destino, lista de precios (`PriceList`) y empleado (`SalesPersonCode`). Los códigos numéricos de las listas se guardan como número (`typedLookup`). Las extensiones de cliente pueden agregar campos a las pestañas (`logisticsLeft/Right`, `financeLeft/Right`). Sin verificar: «Responsable» de Logística (no se identificó su campo del Service Layer), canal del socio, cuota/consolidación de Finanzas. **Solicitudes de compra** (`/compras/solicitudes`, `tools/solicitud de compra.png`) son documentos de marketing sin socio: `marketingLayout({ requester: true })` — cabecera con tipo, solicitante (usuario de SAP: al elegirlo se completan nombre, correo, departamento y sucursal; `ReqType` 12), «Válido hasta», fecha necesaria e indicador/proyecto; líneas con proveedor sugerido (`LineVendor`), fecha necesaria por línea (columna `date` de la grilla) y cantidad/precio/descuento/impuesto/total/almacén; pie con gastos adicionales y totales; sin moneda, redondeo ni pestañas. Para el solicitante la API registra `Users` con `selectOnly` (`UserCode`, `UserName`, `eMail`, `Department`, `Branch`: `$select` obligatorio y limitado; la entidad trae contraseña y permisos), más `Departments` y `Branches`. Los documentos sin socio indican su maestro de impuestos con `DocumentScreen.taxResource`. **La dirección del documento** (`AddressExtension`: `ShipTo…` y `BillTo…`, `address-fields.ts`): el texto bajo el destino y la dirección de facturación se arma con sus partes (calle, número, bloque, edificio, ciudad, código postal, provincia, región, país) del documento guardado o, si se eligió otra, de la dirección del socio; el botón «…» abre una ventana (`address-dialog.ts`) para corregirlas en este documento y solo lo que cambió viaja en `AddressExtension`; elegir otra dirección descarta las correcciones. Las celdas de lista con texto propio para el código (`VhLookup.codes`: la cuenta asociada con su código con formato) lo muestran también en los campos de cabecera (`sbo-lookup-field`). **Cuenta asociada por grupo:** en ventas solo se ofrecen las cuentas asociadas de activo y en compras las de pasivo. El Service Layer no expone `GroupMask` (ni como campo ni como filtro) ni deja filtrar por cuenta padre, y limita la consulta a 2048 caracteres, así que `ControlAccountsService` pide las asociadas (`LockManualTransaction = tYES`), sube por sus cuentas padre en lotes de 40 hasta la de primer nivel y las agrupa por su primer carácter (`1…` activos, `2…` pasivos); se calcula una vez por sesión y la lista se busca y pagina en el navegador (`VhLookup.rows`, `ValueHelp.localRows`). En el asiento la «Cuenta» de un socio aún ofrece todas las asociadas. **Cancelados:** en SAP un documento cancelado (`Cancelled = tYES`, `CancelStatus = csYes`) queda cerrado y SAP crea otro, el documento de cancelación (`CancelStatus = csCancellation`), también cerrado. Las listas de ventas y compras lo muestran en «Estado» como «Cancelado» y «Cancelación» y tienen el filtro «Cancelación» (cancelados / documentos de cancelación). El enlace entre la pareja no es un campo: el documento de cancelación nombra al cancelado en sus comentarios («Basado en … 260013973.»), así que los botones «Ver su cancelación» / «Ver el documento cancelado» del formulario lo buscan por ese número, el socio y el orden. +## Base de datos de SAP (consultas de solo lectura) + +Módulo genérico `apps/api/src/sapdb/` (en `main`, opcional como el Gateway): ejecuta consultas **registradas** de solo lectura directamente en la base de SAP, para lo que el Service Layer no da bien (por ejemplo, el stock de un lote por almacén o el almacén de origen de un traslado). Hoy el motor es **HANA** (`hdb`, JavaScript puro); la interfaz `SapDbAdapter` deja listo sumar **MSSQL** (SAP10 de pruebas) con otro adaptador, y cada consulta declara su SQL por motor. En `main` solo existe `ping`; **las consultas de negocio van en la rama de cada cliente** (`sapdb.extensions.ts`, vacío en `main`) porque dependen de sus tablas y campos de usuario. El frontend nunca manda SQL: pide la consulta por nombre con parámetros tipados (`POST /api/sapdb/queries/:name`); el SQL usa `:nombre` y el servidor lo cambia por `?` (nunca se concatena). Al arrancar se rechaza una consulta que no sea un único `SELECT`/`WITH`. Cola propia (`SAPDB_MAX_CONCURRENCY` 2), tope de filas y tiempo máximo; auditoría `db_query` (nombres, no valores). Variables `SAPDB_*` en el `.env`. Verificado contra `SBO_TAGUMEDICA_21` (conexión, esquema y parámetro). **Aviso:** el `.env` del mini-erp usa el usuario `SYSTEM` de HANA (superusuario): conviene un usuario de la base que solo pueda leer; el módulo igualmente solo ejecuta los `SELECT` registrados. + ## Cómo verificar cambios de interfaz - Probar contra el SAP real con la extensión de Chrome (`list_connected_browsers` debe mostrar un navegador). Si no conecta, usar Chrome headless por CDP. diff --git a/apps/api/.env.example b/apps/api/.env.example index cad3247..941ad4b 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -45,3 +45,21 @@ APIGATEWAY_MAX_CONCURRENCY=2 # Certificado del gateway (PEM, relativo a apps/api) si es autofirmado APIGATEWAY_CA_FILE= APIGATEWAY_VERIFY_SSL=true + +# --- Base de datos de SAP B1 (consultas de solo lectura registradas): opcional, sin motor el módulo queda sin configurar --- +# Hoy solo HANA. Usar un usuario de la base que SOLO pueda leer (nunca SYSTEM). +SAPDB_ENGINE= +SAPDB_HOST= +SAPDB_PORT=30015 +SAPDB_USER= +SAPDB_PASSWORD= +# Esquema de la empresa (en HANA, el nombre de la base: SBO_EMPRESA) +SAPDB_SCHEMA= +SAPDB_TLS=false +SAPDB_CA_FILE= +SAPDB_VERIFY_SSL=true +# Espera máxima (segundos) de cada consulta, consultas a la vez y tope de filas +SAPDB_TIMEOUT=30 +SAPDB_MAX_CONCURRENCY=2 +SAPDB_MAX_ROWS=5000 + diff --git a/apps/api/README.md b/apps/api/README.md index 6f65d98..5919130 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -108,3 +108,13 @@ Llama a `rs/v1/LoadAuthorizedCRList` (los reportes autorizados), `rs/v1/LoadCR` La matrícula (tabla `gateway_reports` de la SQLite) es lo que arma el cuerpo de `ExportPDFData`: el usuario solo manda valores y el servidor los ordena según los parámetros matriculados (nombre y tipo de Crystal), exige los obligatorios y descarta lo que no esté matriculado. La auditoría registra `report_register`, `report_unregister` y `report_export` (solo los nombres de los parámetros, nunca los valores). +## Consultas a la base de datos de SAP (solo lectura) + +Algunas pantallas necesitan datos que el Service Layer no entrega bien (por ejemplo, el stock de un lote por almacén). El módulo `src/sapdb/` ejecuta **consultas registradas, de solo lectura**, directamente en la base de SAP. Hoy el motor es **HANA** (`hdb`, JavaScript puro, sin módulos nativos); la interfaz (`SapDbAdapter`) deja listo sumar MSSQL con su adaptador. + +- **Configuración (`SAPDB_*` en el `.env`):** `SAPDB_ENGINE=hana`, servidor, puerto (30015), usuario, clave y `SAPDB_SCHEMA` (la base de la empresa). Sin motor el módulo responde 503 y el resto de la API no se ve afectado. Usar un usuario de la base que **solo pueda leer**. +- **El frontend nunca manda SQL.** Pide una consulta por su nombre con sus parámetros: `POST /api/sapdb/queries/:name` `{params}` → `{rows, truncated}`; `GET /api/sapdb/queries` lista las que el usuario puede ejecutar. +- **Registro de consultas** (`sapdb-queries.ts` + `sapdb.extensions.ts`, vacío en `main`; la rama de cada cliente agrega las suyas): cada una declara su SQL por motor, sus parámetros con nombre (`:lote`, tipos `string | number | date`), el permiso de menú que basta para ejecutarla (`menu`, o `null` = cualquier sesión) y un tope de filas. Al arrancar se rechaza una consulta que no sea un único `SELECT`/`WITH`, que use verbos que escriben, que repita nombre o cuyos parámetros no coincidan con el SQL. +- **Parámetros:** el SQL lleva `:nombre` y el servidor los cambia por `?` con los valores como argumentos; un valor nunca se concatena al SQL. +- **Operación:** una conexión por consulta (se abre, fija el esquema, ejecuta y cierra; nunca confirma nada), cola propia (`SAPDB_MAX_CONCURRENCY`), tiempo máximo (`SAPDB_TIMEOUT`) y tope de filas (`SAPDB_MAX_ROWS`). La auditoría registra `db_query` con el nombre, los nombres de los parámetros y las filas devueltas, nunca los valores. + diff --git a/apps/api/package.json b/apps/api/package.json index f087115..aee5ce7 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -29,6 +29,7 @@ "@nestjs/platform-express": "^12.0.1", "@nestjs/typeorm": "^12.0.2", "better-sqlite3": "^12.11.1", + "hdb": "^0.19.12", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^1.1.2", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 36449c2..7922367 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -4,13 +4,14 @@ 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 { SapDbModule } from './sapdb/sapdb.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, GatewayModule], + imports: [AppConfigModule, DatabaseModule, SapModule, AuthModule, PermissionsModule, AuditAdminModule, SlModule, GatewayModule, SapDbModule], controllers: [HealthController], }) export class AppModule {} diff --git a/apps/api/src/config/env.schema.spec.ts b/apps/api/src/config/env.schema.spec.ts index cfbfb82..4073ce0 100644 --- a/apps/api/src/config/env.schema.spec.ts +++ b/apps/api/src/config/env.schema.spec.ts @@ -57,6 +57,37 @@ describe('validateEnv', () => { expect(validateEnv({ ...sap, SAPB1_SL_VERIFY_SSL: 'false' }).SAPB1_SL_VERIFY_SSL).toBe(false); }); + describe('base de datos de SAP', () => { + const db = { SAPDB_ENGINE: 'hana', SAPDB_HOST: 'hana.local', SAPDB_USER: 'lector', SAPDB_PASSWORD: 'p', SAPDB_SCHEMA: 'SBO_EMPRESA' }; + + it('es opcional: sin motor queda sin configurar y con valores por defecto', () => { + const env = validateEnv(sap); + expect(env.SAPDB_ENGINE).toBeUndefined(); + expect(env.SAPDB_PORT).toBe(30015); + expect(env.SAPDB_TLS).toBe(false); + expect(env.SAPDB_TIMEOUT).toBe(30); + expect(env.SAPDB_MAX_CONCURRENCY).toBe(2); + expect(env.SAPDB_MAX_ROWS).toBe(5000); + }); + + it('con motor pide servidor, usuario, clave y esquema', () => { + expect(() => validateEnv({ ...sap, SAPDB_ENGINE: 'hana' })).toThrow(/SAPDB_HOST/); + expect(validateEnv({ ...sap, ...db }).SAPDB_ENGINE).toBe('hana'); + }); + + it('el esquema solo admite letras, dígitos y «_» (se escribe en un SET SCHEMA)', () => { + expect(() => validateEnv({ ...sap, ...db, SAPDB_SCHEMA: 'X"; DROP' })).toThrow(/SAPDB_SCHEMA/); + }); + + it('un motor que aún no existe se rechaza', () => { + expect(() => validateEnv({ ...sap, ...db, SAPDB_ENGINE: 'mssql' })).toThrow(/SAPDB_ENGINE/); + }); + + it('no se permite desactivar la validación SSL en producción con TLS', () => { + expect(() => validateEnv({ ...sap, ...db, NODE_ENV: 'production', SAPDB_TLS: 'true', SAPDB_VERIFY_SSL: 'false' })).toThrow(/SAPDB_VERIFY_SSL/); + }); + }); + describe('API Gateway', () => { it('es opcional: sin URL queda sin configurar y con valores por defecto', () => { const env = validateEnv(sap); diff --git a/apps/api/src/config/env.schema.ts b/apps/api/src/config/env.schema.ts index fbb21fe..7c9539a 100644 --- a/apps/api/src/config/env.schema.ts +++ b/apps/api/src/config/env.schema.ts @@ -65,6 +65,29 @@ export const envSchema = z .enum(['true', 'false']) .default('true') .transform((v) => v === 'true'), + // --- Base de datos de SAP B1 (consultas de solo lectura registradas): opcional; sin motor el módulo queda sin configurar --- + SAPDB_ENGINE: z.preprocess(emptyToUndefined, z.enum(['hana']).optional()), + SAPDB_HOST: z.preprocess(emptyToUndefined, z.string().optional()), + SAPDB_PORT: z.coerce.number().int().min(1).max(65535).default(30015), + SAPDB_USER: z.preprocess(emptyToUndefined, z.string().optional()), + SAPDB_PASSWORD: z.preprocess(emptyToUndefined, z.string().optional()), + // Esquema de la empresa (en HANA, el nombre de la base: `SBO_TAGUMEDICA_21`). + SAPDB_SCHEMA: z.preprocess(emptyToUndefined, z.string().regex(/^\w+$/, 'solo letras, dígitos y «_»').optional()), + SAPDB_TLS: z + .enum(['true', 'false']) + .default('false') + .transform((v) => v === 'true'), + SAPDB_CA_FILE: z.preprocess(emptyToUndefined, z.string().optional()), + SAPDB_VERIFY_SSL: z + .enum(['true', 'false']) + .default('true') + .transform((v) => v === 'true'), + // Espera máxima (segundos) de cada consulta. + SAPDB_TIMEOUT: z.coerce.number().positive().default(30), + // Consultas que se ejecutan a la vez; el resto espera su turno (la base de SAP no es para esto). + SAPDB_MAX_CONCURRENCY: z.coerce.number().int().positive().default(2), + // Tope de filas de cualquier consulta; lo que pase se recorta. + SAPDB_MAX_ROWS: z.coerce.number().int().positive().default(5000), SAPB1_SL_VERIFY_SSL: z .enum(['true', 'false']) .default('true') @@ -81,6 +104,15 @@ export const envSchema = z 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.SAPDB_CA_FILE && !existsSync(resolve(env.SAPDB_CA_FILE))) { + ctx.addIssue({ code: 'custom', path: ['SAPDB_CA_FILE'], message: `no existe el archivo ${resolve(env.SAPDB_CA_FILE)}` }); + } + if (env.SAPDB_ENGINE && !(env.SAPDB_HOST && env.SAPDB_USER && env.SAPDB_PASSWORD && env.SAPDB_SCHEMA)) { + ctx.addIssue({ code: 'custom', path: ['SAPDB_ENGINE'], message: 'con SAPDB_ENGINE hacen falta SAPDB_HOST, SAPDB_USER, SAPDB_PASSWORD y SAPDB_SCHEMA' }); + } + if (env.NODE_ENV === 'production' && env.SAPDB_TLS && !env.SAPDB_VERIFY_SSL) { + ctx.addIssue({ code: 'custom', path: ['SAPDB_VERIFY_SSL'], message: 'no se permite desactivar la validación SSL en producción; use SAPDB_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' }); } diff --git a/apps/api/src/sapdb/hana.adapter.ts b/apps/api/src/sapdb/hana.adapter.ts new file mode 100644 index 0000000..651447c --- /dev/null +++ b/apps/api/src/sapdb/hana.adapter.ts @@ -0,0 +1,73 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import hdb from 'hdb'; +import type { SapDbAdapter, SapDbRow } from './sapdb.types.js'; + +export interface HanaOptions { + host: string; + port: number; + user: string; + password: string; + schema: string; + tls: boolean; + caFile?: string; + verifySsl: boolean; +} + +/** + * Adaptador de SAP HANA con `hdb` (JavaScript puro, sin módulos nativos). Una conexión por consulta (la cola del + * servicio limita cuántas hay a la vez): se abre, fija el esquema de la empresa, ejecuta y se cierra. Nunca confirma + * nada (`autoCommit` apagado); el usuario de la base debe ser de solo lectura. + */ +export class HanaAdapter implements SapDbAdapter { + constructor(private readonly options: HanaOptions) {} + + query(sql: string, args: readonly unknown[], opts: { timeoutMs: number }): Promise { + const o = this.options; + return new Promise((done, fail) => { + const client = hdb.createClient({ + host: o.host, + port: o.port, + user: o.user, + password: o.password, + useTLS: o.tls, + ...(o.tls && o.caFile ? { ca: readFileSync(resolve(o.caFile), 'utf8') } : {}), + ...(o.tls ? { rejectUnauthorized: o.verifySsl } : {}), + }); + let settled = false; + const finish = (err: Error | null, rows?: SapDbRow[]): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + client.end(); + } catch { + /* la conexión ya se cerró */ + } + if (err) fail(err); + else done(rows ?? []); + }; + const timer = setTimeout(() => { + client.destroy(); + finish(new Error(`La base de datos no respondió en ${Math.round(opts.timeoutMs / 1000)} s`)); + }, opts.timeoutMs); + + client.on('error', (err: Error) => finish(err)); + client.connect((err?: Error) => { + if (err) return finish(err); + client.setAutoCommit(false); + // el esquema ya se validó al leer la configuración (solo letras, dígitos y «_») + client.exec(`SET SCHEMA "${o.schema}"`, (e?: Error) => { + if (e) return finish(e); + client.prepare(sql, (pe: Error | undefined, stmt) => { + if (pe || !stmt) return finish(pe ?? new Error('No se pudo preparar la consulta')); + stmt.exec([...args], (xe: Error | undefined, rows?: unknown) => { + stmt.drop(() => undefined); + finish(xe ?? null, Array.isArray(rows) ? (rows as SapDbRow[]) : []); + }); + }); + }); + }); + }); + } +} diff --git a/apps/api/src/sapdb/hdb.d.ts b/apps/api/src/sapdb/hdb.d.ts new file mode 100644 index 0000000..ad9d4f9 --- /dev/null +++ b/apps/api/src/sapdb/hdb.d.ts @@ -0,0 +1,18 @@ +/** Lo mínimo de `hdb` (sin tipos propios) que usa el adaptador de HANA. */ +declare module 'hdb' { + interface Statement { + exec(args: unknown[], cb: (err?: Error, rows?: unknown) => void): void; + drop(cb: () => void): void; + } + interface Client { + connect(cb: (err?: Error) => void): void; + setAutoCommit(on: boolean): void; + exec(sql: string, cb: (err?: Error) => void): void; + prepare(sql: string, cb: (err: Error | undefined, stmt?: Statement) => void): void; + on(event: 'error', cb: (err: Error) => void): void; + end(): void; + destroy(): void; + } + const hdb: { createClient(options: Record): Client }; + export default hdb; +} diff --git a/apps/api/src/sapdb/sapdb-queries.ts b/apps/api/src/sapdb/sapdb-queries.ts new file mode 100644 index 0000000..e360b5a --- /dev/null +++ b/apps/api/src/sapdb/sapdb-queries.ts @@ -0,0 +1,36 @@ +import { assertReadOnly, paramNames } from './sapdb-sql.js'; +import { CLIENT_SAPDB_QUERIES } from './sapdb.extensions.js'; +import type { SapDbQuery } from './sapdb.types.js'; + +/** Consultas del proyecto base: solo una de comprobación (la base responde). Las de negocio van en `sapdb.extensions.ts`. */ +export const BASE_SAPDB_QUERIES: readonly SapDbQuery[] = [ + { + name: 'ping', + title: 'Comprobar la conexión con la base de datos', + sql: { hana: 'SELECT 1 AS "ok" FROM "DUMMY"' }, + params: {}, + menu: null, + }, +]; + +/** Comprueba que los nombres no se repitan, que el SQL sea de solo lectura y que use justo los parámetros declarados. Lanza si no. */ +export function validateQueries(queries: readonly SapDbQuery[]): readonly SapDbQuery[] { + const seen = new Set(); + for (const q of queries) { + if (!/^[a-z0-9][a-z0-9-]{0,59}$/.test(q.name)) throw new Error(`Nombre de consulta inválido: ${q.name}`); + if (seen.has(q.name)) throw new Error(`Consulta repetida: ${q.name}`); + seen.add(q.name); + const engines = Object.entries(q.sql); + if (!engines.length) throw new Error(`La consulta ${q.name} no tiene SQL`); + for (const [engine, sql] of engines) { + assertReadOnly(`${q.name} (${engine})`, sql); + const used = new Set(paramNames(sql)); + const declared = new Set(Object.keys(q.params)); + for (const n of used) if (!declared.has(n)) throw new Error(`La consulta ${q.name} (${engine}) usa :${n} sin declararlo`); + for (const n of declared) if (!used.has(n)) throw new Error(`La consulta ${q.name} (${engine}) declara ${n} y no lo usa`); + } + } + return queries; +} + +export const SAPDB_QUERIES: ReadonlyMap = new Map(validateQueries([...BASE_SAPDB_QUERIES, ...CLIENT_SAPDB_QUERIES]).map((q) => [q.name, q])); diff --git a/apps/api/src/sapdb/sapdb-sql.spec.ts b/apps/api/src/sapdb/sapdb-sql.spec.ts new file mode 100644 index 0000000..488f3bf --- /dev/null +++ b/apps/api/src/sapdb/sapdb-sql.spec.ts @@ -0,0 +1,67 @@ +import { BadRequestException } from '@nestjs/common'; +import { assertReadOnly, bindNamed, paramNames, parseParams } from './sapdb-sql.js'; +import { validateQueries } from './sapdb-queries.js'; +import type { SapDbQuery } from './sapdb.types.js'; + +describe('assertReadOnly', () => { + it('acepta SELECT y WITH, con comentarios y literales que parecen verbos', () => { + expect(() => assertReadOnly('q', 'SELECT "UpdateDate", \'delete me\' AS "x" FROM "OBTN" -- drop table\nWHERE 1 = 1')).not.toThrow(); + expect(() => assertReadOnly('q', 'WITH a AS (SELECT 1 AS "n" FROM "DUMMY") SELECT * FROM a')).not.toThrow(); + expect(() => assertReadOnly('q', "SELECT REPLACE(\"ItemName\", 'a', 'b') AS \"n\" FROM \"OITM\"")).not.toThrow(); + }); + + it('rechaza lo que escribe, varias sentencias y lo que no empieza por SELECT', () => { + for (const sql of ['DELETE FROM "OITM"', 'UPDATE "OITM" SET "a" = 1', 'SELECT 1 FROM "DUMMY"; DROP TABLE "OITM"', 'CALL "SP"()', 'SELECT 1 FROM "DUMMY" /* x */ ; SELECT 2 FROM "DUMMY"', 'WITH x AS (SELECT 1 AS "a" FROM "DUMMY") INSERT INTO "T" SELECT * FROM x']) { + expect(() => assertReadOnly('q', sql), sql).toThrow(); + } + }); +}); + +describe('parámetros con nombre', () => { + it('los nombres salen en orden y se ignoran los de literales, identificadores, comentarios y «::»', () => { + expect(paramNames('SELECT :a, \':no\', ":tampoco", x::int FROM t WHERE b = :b AND c = :a -- :fuera')).toEqual(['a', 'b', 'a']); + }); + + it('se cambian por «?» y los argumentos van en el mismo orden (un nombre repetido se repite)', () => { + const r = bindNamed('SELECT * FROM t WHERE a = :a AND b = :b AND c = :a AND d = \':a\'', { a: 1, b: 'x' }); + expect(r.sql).toBe("SELECT * FROM t WHERE a = ? AND b = ? AND c = ? AND d = ':a'"); + expect(r.args).toEqual([1, 'x', 1]); + }); + + it('un valor que parece SQL viaja como argumento, nunca dentro del texto', () => { + const r = bindNamed('SELECT 1 FROM t WHERE a = :a', { a: "x'; DROP TABLE t; --" }); + expect(r.sql).toBe('SELECT 1 FROM t WHERE a = ?'); + expect(r.args).toEqual(["x'; DROP TABLE t; --"]); + }); +}); + +describe('parseParams', () => { + const q: SapDbQuery = { + name: 'demo', + title: 'Demo', + sql: { hana: 'SELECT 1 FROM "DUMMY" WHERE :lote = :lote AND :n = :n AND :f = :f AND :o = :o' }, + params: { lote: { type: 'string' }, n: { type: 'number' }, f: { type: 'date' }, o: { type: 'string', required: false } }, + menu: null, + }; + + it('comprueba tipos, exige los obligatorios y deja los opcionales en null', () => { + expect(parseParams(q, { lote: ' L1 ', n: '3', f: '2026-10-20' })).toEqual({ lote: 'L1', n: 3, f: '2026-10-20', o: null }); + expect(() => parseParams(q, { n: 1, f: '2026-10-20' })).toThrow(BadRequestException); + expect(() => parseParams(q, { lote: 'a', n: 'x', f: '2026-10-20' })).toThrow(/número/); + expect(() => parseParams(q, { lote: 'a', n: 1, f: '20/10/2026' })).toThrow(/yyyy-MM-dd/); + expect(() => parseParams(q, { lote: 'a', n: 1, f: '2026-10-20', otro: 'x' })).toThrow(/desconocidos/); + }); +}); + +describe('validateQueries', () => { + const base: SapDbQuery = { name: 'a', title: 'A', sql: { hana: 'SELECT 1 AS "x" FROM "DUMMY"' }, params: {}, menu: null }; + + it('rechaza nombres repetidos, SQL que escribe y parámetros sin declarar o sin usar', () => { + expect(() => validateQueries([base, base])).toThrow(/repetida/); + expect(() => validateQueries([{ ...base, sql: { hana: 'DELETE FROM "T"' } }])).toThrow(/SELECT/); + expect(() => validateQueries([{ ...base, sql: { hana: 'SELECT :x FROM "DUMMY"' } }])).toThrow(/sin declararlo/); + expect(() => validateQueries([{ ...base, params: { x: { type: 'string' } } }])).toThrow(/no lo usa/); + expect(() => validateQueries([{ ...base, name: 'Malo Nombre' }])).toThrow(/inválido/); + expect(validateQueries([base])).toHaveLength(1); + }); +}); diff --git a/apps/api/src/sapdb/sapdb-sql.ts b/apps/api/src/sapdb/sapdb-sql.ts new file mode 100644 index 0000000..a9ecfc7 --- /dev/null +++ b/apps/api/src/sapdb/sapdb-sql.ts @@ -0,0 +1,112 @@ +import { BadRequestException } from '@nestjs/common'; +import type { SapDbParamType, SapDbQuery } from './sapdb.types.js'; + +/** Quita comentarios y el contenido de los literales (`'…'`) y de los identificadores entre comillas (`"…"`), para revisar solo el SQL. */ +function skeleton(sql: string): string { + let out = ''; + let i = 0; + while (i < sql.length) { + const c = sql[i]; + if (c === '-' && sql[i + 1] === '-') { + while (i < sql.length && sql[i] !== '\n') i++; + } else if (c === '/' && sql[i + 1] === '*') { + const end = sql.indexOf('*/', i + 2); + i = end === -1 ? sql.length : end + 2; + } else if (c === "'" || c === '"') { + out += c + c; + i++; + while (i < sql.length && !(sql[i] === c && sql[i + 1] !== c)) i += sql[i] === c ? 2 : 1; + i++; + } else { + out += c; + i++; + } + } + return out; +} + +const FORBIDDEN = /\b(insert|update|delete|merge|upsert|drop|alter|create|truncate|grant|revoke|call|exec|execute|commit|rollback)\b/i; + +/** Una consulta registrada solo puede leer: un único `SELECT` o `WITH`, sin `;` ni verbos que escriben. Lanza si no cumple. */ +export function assertReadOnly(name: string, sql: string): void { + const s = skeleton(sql).trim(); + if (!/^(select|with)\b/i.test(s)) throw new Error(`La consulta ${name} debe empezar por SELECT o WITH`); + if (s.includes(';')) throw new Error(`La consulta ${name} no puede llevar «;»`); + const bad = FORBIDDEN.exec(s); + if (bad) throw new Error(`La consulta ${name} no puede usar «${bad[1].toUpperCase()}» (solo lectura)`); +} + +/** Los `:nombre` que usa un SQL fuera de literales e identificadores, en orden de aparición. */ +export function paramNames(sql: string): string[] { + const out: string[] = []; + const s = skeleton(sql); + for (const m of s.matchAll(/(?>): { sql: string; args: unknown[] } { + const args: unknown[] = []; + let out = ''; + let i = 0; + while (i < sql.length) { + const c = sql[i]; + if (c === '-' && sql[i + 1] === '-') { + const end = sql.indexOf('\n', i); + const stop = end === -1 ? sql.length : end; + out += sql.slice(i, stop); + i = stop; + } else if (c === '/' && sql[i + 1] === '*') { + const end = sql.indexOf('*/', i + 2); + const stop = end === -1 ? sql.length : end + 2; + out += sql.slice(i, stop); + i = stop; + } else if (c === "'" || c === '"') { + let j = i + 1; + while (j < sql.length && !(sql[j] === c && sql[j + 1] !== c)) j += sql[j] === c ? 2 : 1; + out += sql.slice(i, j + 1); + i = j + 1; + } else if (c === ':' && sql[i - 1] !== ':' && /[A-Za-z_]/.test(sql[i + 1] ?? '')) { + const m = /^[A-Za-z_]\w*/.exec(sql.slice(i + 1))!; + args.push(values[m[0]] ?? null); + out += '?'; + i += 1 + m[0].length; + } else { + out += c; + i++; + } + } + return { sql: out, args }; +} + +const DATE = /^\d{4}-\d{2}-\d{2}$/; + +function coerce(name: string, type: SapDbParamType, raw: unknown): string | number { + if (type === 'number') { + const n = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN; + if (!Number.isFinite(n)) throw new BadRequestException(`El parámetro ${name} debe ser un número`); + return n; + } + if (typeof raw !== 'string') throw new BadRequestException(`El parámetro ${name} debe ser un texto`); + const text = raw.trim(); + if (text.length > 200) throw new BadRequestException(`El parámetro ${name} es demasiado largo`); + if (type === 'date' && !DATE.test(text)) throw new BadRequestException(`El parámetro ${name} debe ser una fecha yyyy-MM-dd`); + return text; +} + +/** Los valores recibidos según los parámetros declarados: tipo comprobado, obligatorios exigidos y sin nombres de más. */ +export function parseParams(query: SapDbQuery, raw: Readonly>): Record { + const unknown = Object.keys(raw).filter((k) => !(k in query.params)); + if (unknown.length) throw new BadRequestException(`Parámetros desconocidos: ${unknown.join(', ')}`); + const out: Record = {}; + for (const [name, def] of Object.entries(query.params)) { + const v = raw[name]; + if (v === undefined || v === null || v === '') { + if (def.required ?? true) throw new BadRequestException(`Falta el parámetro ${name}`); + out[name] = null; + } else { + out[name] = coerce(name, def.type, v); + } + } + return out; +} diff --git a/apps/api/src/sapdb/sapdb.controller.ts b/apps/api/src/sapdb/sapdb.controller.ts new file mode 100644 index 0000000..b2d2f93 --- /dev/null +++ b/apps/api/src/sapdb/sapdb.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, HttpCode, Param, Post, Req } from '@nestjs/common'; +import { z } from 'zod'; +import type { AuthenticatedRequest } from '../auth/auth.types.js'; +import { ZodValidationPipe } from '../common/zod-validation.pipe.js'; +import { SapDbService } from './sapdb.service.js'; + +const nameSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,59}$/, 'Nombre de consulta inválido'); +const bodySchema = z.object({ params: z.record(z.string(), z.union([z.string().max(200), z.number()])).default({}) }); +type Body_ = z.infer; + +/** Consultas de solo lectura a la base de datos de SAP (las registradas; el permiso lo declara cada una). */ +@Controller('sapdb') +export class SapDbController { + constructor(private readonly sapdb: SapDbService) {} + + @Get('queries') + queries(@Req() req: AuthenticatedRequest) { + return this.sapdb.available(req.user); + } + + @Post('queries/:name') + @HttpCode(200) + run( + @Req() req: AuthenticatedRequest, + @Param('name', new ZodValidationPipe(nameSchema)) name: string, + @Body(new ZodValidationPipe(bodySchema)) body: Body_, + ) { + return this.sapdb.run(req.user, name, body.params, req.ip ?? ''); + } +} diff --git a/apps/api/src/sapdb/sapdb.extensions.ts b/apps/api/src/sapdb/sapdb.extensions.ts new file mode 100644 index 0000000..4ea44cc --- /dev/null +++ b/apps/api/src/sapdb/sapdb.extensions.ts @@ -0,0 +1,7 @@ +import type { SapDbQuery } from './sapdb.types.js'; + +/** + * Consultas a la base de SAP que agrega la rama de cada cliente (vacío en `main`): el SQL depende de sus tablas y de + * sus campos de usuario. Cada una declara su SQL por motor (`hana`), sus parámetros con nombre y el permiso de menú. + */ +export const CLIENT_SAPDB_QUERIES: readonly SapDbQuery[] = []; diff --git a/apps/api/src/sapdb/sapdb.module.ts b/apps/api/src/sapdb/sapdb.module.ts new file mode 100644 index 0000000..1c2703a --- /dev/null +++ b/apps/api/src/sapdb/sapdb.module.ts @@ -0,0 +1,36 @@ +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module.js'; +import { AppConfigService } from '../config/app-config.service.js'; +import { PermissionsModule } from '../permissions/permissions.module.js'; +import { HanaAdapter } from './hana.adapter.js'; +import { SapDbController } from './sapdb.controller.js'; +import { SapDbService } from './sapdb.service.js'; +import { SAPDB_ADAPTER, type SapDbAdapter } from './sapdb.types.js'; + +/** Consultas de solo lectura a la base de SAP B1 (hoy HANA): su propia cola y sus propias consultas registradas. */ +@Module({ + imports: [AuditModule, PermissionsModule], + controllers: [SapDbController], + providers: [ + { + provide: SAPDB_ADAPTER, + inject: [AppConfigService], + // sin motor configurado no hay adaptador: el servicio responde 503 y el resto de la API sigue igual + useFactory: (config: AppConfigService): SapDbAdapter | null => + config.get('SAPDB_ENGINE') === 'hana' + ? new HanaAdapter({ + host: config.get('SAPDB_HOST')!, + port: config.get('SAPDB_PORT'), + user: config.get('SAPDB_USER')!, + password: config.get('SAPDB_PASSWORD')!, + schema: config.get('SAPDB_SCHEMA')!, + tls: config.get('SAPDB_TLS'), + caFile: config.get('SAPDB_CA_FILE'), + verifySsl: config.get('SAPDB_VERIFY_SSL'), + }) + : null, + }, + SapDbService, + ], +}) +export class SapDbModule {} diff --git a/apps/api/src/sapdb/sapdb.permissions.spec.ts b/apps/api/src/sapdb/sapdb.permissions.spec.ts new file mode 100644 index 0000000..b0dde0d --- /dev/null +++ b/apps/api/src/sapdb/sapdb.permissions.spec.ts @@ -0,0 +1,61 @@ +import { ForbiddenException } from '@nestjs/common'; +import { vi } from 'vitest'; +import type { AuditService } from '../audit/audit.service.js'; +import type { AuthUser } from '../auth/auth.types.js'; +import type { AppConfigService } from '../config/app-config.service.js'; +import type { PermissionsService } from '../permissions/permissions.service.js'; +import type { SapDbAdapter, SapDbQuery } from './sapdb.types.js'; + +// un registro con una consulta que exige permiso de menú (la de `main` no lo exige) +vi.mock('./sapdb-queries.js', () => { + const q: SapDbQuery = { + name: 'lot-stock', + title: 'Stock de un lote', + sql: { hana: 'SELECT "WhsCode" FROM "OBTQ" WHERE "DistNumber" = :lote' }, + params: { lote: { type: 'string' } }, + menu: ['/inventario/lotes', '/inventario/liberar-lotes'], + }; + return { SAPDB_QUERIES: new Map([[q.name, q]]) }; +}); + +const { SapDbService } = await import('./sapdb.service.js'); +const user: AuthUser = { webUser: 'ana', empId: 7, firstName: 'Ana', lastName: 'P' }; + +describe('permiso de una consulta', () => { + const query = vi.fn(async () => [{ WhsCode: '201' }]); + const record = vi.fn(async () => undefined); + let paths: string[]; + const service = () => + new SapDbService( + { query } as SapDbAdapter, + { get: (k: string) => ({ SAPDB_ENGINE: 'hana', SAPDB_MAX_CONCURRENCY: 1, SAPDB_MAX_ROWS: 100, SAPDB_TIMEOUT: 5 })[k] } as unknown as AppConfigService, + { getPaths: async () => paths } as unknown as PermissionsService, + { record } as unknown as AuditService, + ); + + beforeEach(() => { + query.mockClear(); + record.mockClear(); + }); + + it('basta el permiso de cualquiera de las rutas que declara la consulta', async () => { + paths = ['/inventario/liberar-lotes']; + expect((await service().run(user, 'lot-stock', { lote: 'L1' }, '')).rows).toEqual([{ WhsCode: '201' }]); + expect(query).toHaveBeenCalledWith('SELECT "WhsCode" FROM "OBTQ" WHERE "DistNumber" = ?', ['L1'], expect.anything()); + }); + + it('sin permiso se deniega, se audita y no se consulta; tampoco aparece en la lista', async () => { + paths = ['/ventas/*']; + await expect(service().run(user, 'lot-stock', { lote: 'L1' }, '')).rejects.toThrow(ForbiddenException); + expect(query).not.toHaveBeenCalled(); + expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'db_query', outcome: 'denied', resource: 'lot-stock' })); + expect(await service().available(user)).toEqual([]); + }); + + it('un parámetro de más o de menos se rechaza antes de llegar a la base', async () => { + paths = ['*']; + await expect(service().run(user, 'lot-stock', {}, '')).rejects.toThrow(/Falta/); + await expect(service().run(user, 'lot-stock', { lote: 'L1', x: '1' }, '')).rejects.toThrow(/desconocidos/); + expect(query).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/sapdb/sapdb.service.spec.ts b/apps/api/src/sapdb/sapdb.service.spec.ts new file mode 100644 index 0000000..21b56d7 --- /dev/null +++ b/apps/api/src/sapdb/sapdb.service.spec.ts @@ -0,0 +1,84 @@ +import { BadGatewayException, NotFoundException, ServiceUnavailableException } from '@nestjs/common'; +import { vi } from 'vitest'; +import type { AuditService } from '../audit/audit.service.js'; +import type { AuthUser } from '../auth/auth.types.js'; +import type { AppConfigService } from '../config/app-config.service.js'; +import type { PermissionsService } from '../permissions/permissions.service.js'; +import { SapDbService } from './sapdb.service.js'; +import type { SapDbAdapter } from './sapdb.types.js'; + +const user: AuthUser = { webUser: 'ana', empId: 7, firstName: 'Ana', lastName: 'P' }; + +describe('SapDbService', () => { + let query: ReturnType; + let record: ReturnType; + let paths: string[]; + let max: number; + + const make = (adapter: SapDbAdapter | null) => + new SapDbService( + adapter, + { get: (k: string) => ({ SAPDB_ENGINE: 'hana', SAPDB_MAX_CONCURRENCY: max, SAPDB_MAX_ROWS: 3, SAPDB_TIMEOUT: 5 })[k] } as unknown as AppConfigService, + { getPaths: async () => paths } as unknown as PermissionsService, + { record } as unknown as AuditService, + ); + + beforeEach(() => { + query = vi.fn(async () => [{ ok: 1 }]); + record = vi.fn(async () => undefined); + paths = ['*']; + max = 2; + }); + + it('sin base configurada responde 503', async () => { + const s = make(null); + expect(s.configured).toBe(false); + await expect(s.run(user, 'ping', {}, '10.0.0.5')).rejects.toThrow(ServiceUnavailableException); + }); + + it('ejecuta una consulta registrada y audita solo los nombres de los parámetros', async () => { + const s = make({ query } as SapDbAdapter); + expect(await s.run(user, 'ping', {}, '10.0.0.5')).toEqual({ rows: [{ ok: 1 }], truncated: false }); + expect(query).toHaveBeenCalledWith('SELECT 1 AS "ok" FROM "DUMMY"', [], { timeoutMs: 5000 }); + expect(record).toHaveBeenCalledWith(expect.objectContaining({ action: 'db_query', resource: 'ping', detail: expect.objectContaining({ params: [], rows: 1 }) })); + }); + + it('una consulta que no existe da 404 y nunca llega a la base', async () => { + await expect(make({ query } as SapDbAdapter).run(user, 'drop-everything', {}, '')).rejects.toThrow(NotFoundException); + expect(query).not.toHaveBeenCalled(); + }); + + it('recorta lo que pasa del tope de filas y lo avisa', async () => { + query.mockResolvedValue([{ n: 1 }, { n: 2 }, { n: 3 }, { n: 4 }]); + const r = await make({ query } as SapDbAdapter).run(user, 'ping', {}, ''); + expect(r.rows).toHaveLength(3); + expect(r.truncated).toBe(true); + }); + + it('un error de la base se audita y sale como 502 con el mensaje', async () => { + query.mockRejectedValue(new Error('invalid table name: OXXX')); + await expect(make({ query } as SapDbAdapter).run(user, 'ping', {}, '')).rejects.toThrow(BadGatewayException); + expect(record).toHaveBeenCalledWith(expect.objectContaining({ outcome: 'error', detail: expect.objectContaining({ error: 'invalid table name: OXXX' }) })); + }); + + it('la cola deja pasar solo SAPDB_MAX_CONCURRENCY consultas a la vez', async () => { + max = 1; + let running = 0; + let peak = 0; + query.mockImplementation(async () => { + running++; + peak = Math.max(peak, running); + await new Promise((r) => setTimeout(r, 5)); + running--; + return []; + }); + const s = make({ query } as SapDbAdapter); + await Promise.all([s.run(user, 'ping', {}, ''), s.run(user, 'ping', {}, ''), s.run(user, 'ping', {}, '')]); + expect(peak).toBe(1); + expect(query).toHaveBeenCalledTimes(3); + }); + + it('lista las consultas disponibles', async () => { + expect(await make({ query } as SapDbAdapter).available(user)).toEqual([{ name: 'ping', title: expect.any(String) }]); + }); +}); diff --git a/apps/api/src/sapdb/sapdb.service.ts b/apps/api/src/sapdb/sapdb.service.ts new file mode 100644 index 0000000..ed23254 --- /dev/null +++ b/apps/api/src/sapdb/sapdb.service.ts @@ -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(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)}`); + } + } +} diff --git a/apps/api/src/sapdb/sapdb.types.ts b/apps/api/src/sapdb/sapdb.types.ts new file mode 100644 index 0000000..fc06478 --- /dev/null +++ b/apps/api/src/sapdb/sapdb.types.ts @@ -0,0 +1,39 @@ +/** Motores de base de datos de SAP B1 que el módulo sabe consultar (hoy solo HANA; MSSQL se suma con su adaptador). */ +export type SapDbEngine = 'hana'; + +export type SapDbParamType = 'string' | 'number' | 'date'; + +/** + * Una consulta registrada: el frontend pide `name` con sus parámetros y nunca manda SQL. Lo que pasa a la base es + * siempre el SQL del registro, con los parámetros como marcadores (`:nombre`), nunca concatenados. + */ +export interface SapDbQuery { + /** Identificador estable (`lot-stock`); es lo que viaja en la URL y en la auditoría. */ + name: string; + title: string; + /** El SQL por motor (solo `SELECT`/`WITH`); una consulta puede declarar solo el motor que usa su cliente. */ + sql: Partial>; + /** Parámetros con nombre (`:warehouse` en el SQL). */ + params: Readonly>; + /** Ruta(s) de menú cuyo permiso basta para ejecutarla; `null` = cualquier sesión. */ + menu: string | readonly string[] | null; + /** Tope de filas de esta consulta (no puede superar `SAPDB_MAX_ROWS`). */ + maxRows?: number; +} + +export type SapDbRow = Record; + +/** Lo que un motor implementa: ejecutar un `SELECT` con argumentos posicionales y devolver filas. */ +export interface SapDbAdapter { + query(sql: string, args: readonly unknown[], opts: { timeoutMs: number }): Promise; +} + +/** Respuesta de una consulta. */ +export interface SapDbResult { + rows: SapDbRow[]; + /** Hubo más filas que el tope y se recortó. */ + truncated: boolean; +} + +/** Token de inyección del adaptador del motor configurado (`null` si el módulo no está configurado). */ +export const SAPDB_ADAPTER = Symbol('SAPDB_ADAPTER'); diff --git a/libs/sap-types/src/audit.ts b/libs/sap-types/src/audit.ts index 59a8946..17cc248 100644 --- a/libs/sap-types/src/audit.ts +++ b/libs/sap-types/src/audit.ts @@ -11,7 +11,8 @@ export type AuditAction = | 'permissions_remove' | 'report_export' | 'report_register' - | 'report_unregister'; + | 'report_unregister' + | 'db_query'; export type AuditOutcome = 'ok' | 'error' | 'denied'; diff --git a/package-lock.json b/package-lock.json index b5f8076..a18b13a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@nestjs/platform-express": "^12.0.1", "@nestjs/typeorm": "^12.0.2", "better-sqlite3": "^12.11.1", + "hdb": "^0.19.12", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^1.1.2", @@ -7717,6 +7718,30 @@ "node": ">= 0.4" } }, + "node_modules/hdb": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/hdb/-/hdb-0.19.12.tgz", + "integrity": "sha512-vv+cjmvr6fNH/s0Q2zOZc4sEjMpSC0KuacFn8dp3L38qM3RA2LLeX70wWhZLESpwvwUf1pQkRfUhZeooFSmv3A==", + "license": "Apache-2.0", + "dependencies": { + "iconv-lite": "^0.4.18" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/hdb/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/hosted-git-info": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-10.1.1.tgz",