commit e1c8ee90159697563c8e0051acae802a8adcc15a Author: George Date: Wed Sep 16 08:30:45 2026 -0500 Initial commit: sap-legacy-webhook-emulator Reconstruye el Webhook Messenger nativo de SAP B1 (FP2605) para clientes que aun no estan en esa version: captura eventos via SBO_SP_PostTransactionNotice, provisiona UserTables OEVC/EVC1/OEVN/EVN1 y despacha CloudEvents al mismo endpoint que el messenger real. Co-Authored-By: Claude Sonnet 5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..909e04a --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Service Layer del cliente que todavía no tiene el Webhook Messenger nativo +SL_BASE_URL="https://sap-servidor-cliente:50000/b1s/v1" +SL_COMPANY_DB="NOMBRE_COMPANIA" +SL_USERNAME="manager" +SL_PASSWORD="" + +# Cada cuántos segundos el dispatcher revisa @OEVN en busca de eventos nuevos +POLL_INTERVAL_SECONDS=30 + +# Reintentos por suscripción antes de pasarla a State='X' (Exceptional) +RETRY_LIMIT=5 + +# El destino y la autenticación de cada envío NO se configuran acá: salen +# de @OEVC (WebhookURL/AuthType/AuthCred), administrado con +# `scripts/manage-subscriptions.ts` — una suscripción puede apuntar a +# cualquier URL (normalmente una instancia de sap-webhook-api). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5beb4dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +# Keep environment variables out of version control +.env +logs/ +tmp-test-events/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..69f4c0c --- /dev/null +++ b/README.md @@ -0,0 +1,178 @@ +# sap-legacy-webhook-emulator + +Reconstruye el mecanismo del **Webhook Messenger nativo** de SAP Business +One (disponible desde FP2605) para clientes que todavía no están en esa +versión, sin tocar nada de [`sap-webhook-api`](../sap-webhook-api): termina +mandando **el mismo payload CloudEvents contra el mismo endpoint** +(`POST /webhooks/sap`) que mandaría el Messenger real. + +Ver [`docs/DISENO.md`](./docs/DISENO.md) para el diseño completo: por qué +`SBO_SP_PostTransactionNotice` y no `SBO_SP_TransactionNotification`, por +qué las 4 tablas son `UserTablesMD` en vez de SQL crudo, el diccionario de +datos completo (esquema real de SAP vs. el que implementamos), los mapeos +`ObjectType`↔`BizObject` / `TransType`↔`Operation`, y el diagrama de flujo +del dispatcher. + +## Cómo funciona + +1. **Captura**: la lógica vive en `SP_Webhook_CaptureEvent` + (`sql/transaction-notification/sp-webhook-capture-event-mssql.sql`) — + hace un único `INSERT` en la UserTable `@OEVN` si el evento no es un eco + de una escritura nuestra (ver `docs/DISENO.md`, hallazgo 5) y hay al + menos una suscripción activa interesada en ese `ObjectType`+`TransType` + (`EXISTS` contra `@EVC1`/`@OEVC` — lee la config real, no duplica + ninguna lista). El hook compartido de SAP, `SBO_SP_PostTransactionNotice` + (`sql/transaction-notification/alter_sbo_sp_posttransactionnotice.sql`), + queda reducido a un solo `EXEC` hacia ese procedimiento — así nuestra + lógica es identificable y no se mezcla con la de otros add-ons que + puedan tener código en ese mismo hook. + ⚠️ El filtro por suscripción activa está escrito pero no reinstalado ni + reverificado contra un ambiente real todavía — ver `docs/DISENO.md`, + "Pendientes" ítem 0. +2. **Provisioning**: `npm run provision` crea (si no existen) las 4 + UserTables — `@OEVC`/`@EVC1`/`@OEVN`/`@EVN1` — vía Service Layer + (`UserTablesMD`/`UserFieldsMD`), replicando el modelo de datos del + Webhook Messenger real. +3. **Dispatcher** (`src/dispatcher/`, corre con `npm start`): cada + `POLL_INTERVAL_SECONDS`, un tick hace dos pasadas: + - Registra eventos nuevos (`@OEVN` con `Status='N'`): resuelve + `BizObject`/`Operation` (`src/mapping/`), los matchea contra las + suscripciones activas en `@OEVC`+`@EVC1`, crea las filas de `@EVN1` + que falten, y descarta (borra) la fila de `@OEVN` si el `ObjectType` + no mapea a nada conocido o si no hay ninguna suscripción interesada. + - Despacha lo pendiente (`@EVN1` con `Status='F'`): agrupa por + suscripción, arma el batch de CloudEvents y hace un solo `POST` por + `WebhookURL` (firmando con HMAC si la suscripción es `AuthType='H'`), + actualiza `@EVN1`/`@OEVN`, e incrementa `Retry` en cada fallo. Al + superar `RETRY_LIMIT`, la suscripción pasa a `State='X'` (Exceptional) + y deja de reintentarse hasta que se reactive a mano. + +## Quick start + +```bash +npm install +cp .env.example .env # completar SL_* / POLL_INTERVAL_SECONDS / RETRY_LIMIT + +npm run provision # crea las 4 UserTables en la company DB del cliente +``` + +Si preferís revisar o postear el body exacto a mano (Postman, curl, +`tools/sl.http`) en vez de correr `npm run provision`, están en +`json/user-tables/*.json` — uno por tabla, con `table` (body de +`POST /UserTablesMD`) y `fields` (bodies de `POST /UserFieldsMD`, en orden). +Se regeneran con `npx tsx scripts/export-table-json.ts` si cambia +`table-definitions.ts`. + +Después, instalar en esa company DB, en este orden (SQL Server): + +1. `sql/transaction-notification/sp-webhook-capture-event-mssql.sql` + (crea `SP_Webhook_CaptureEvent`). +2. `sql/transaction-notification/alter_sbo_sp_posttransactionnotice.sql` (reemplaza el cuerpo de + `SBO_SP_PostTransactionNotice`, que SAP crea vacío por cada compañía + nueva, con el `EXEC` hacia el procedimiento anterior). + +En HANA es el mismo orden, con `sp-webhook-capture-event-hana.sql` y +`recreate_sbo_sp_posttransactionnotice_hana.sql` (que hace `DROP`+`CREATE` +en vez de `ALTER`, porque HANA no soporta modificar el cuerpo de un +procedimiento existente). ✅ Ambos ya se probaron e instalaron contra un +SAP Business One sobre HANA real (captura y entrega confirmadas de punta +a punta) — ⚠️ pero la versión *actual* del archivo (con el filtro por +suscripción activa, ver más arriba) todavía no se reinstaló ni reverificó +después de ese cambio. + +Antes de arrancar el dispatcher, dar de alta al menos una suscripción (el +destino y la autenticación salen de acá, no de `.env`). Para probar sin +necesitar una instancia real de `sap-webhook-api`, usar +[`sap-test-legacy-webhook`](../sap-test-legacy-webhook) (receptor mínimo +de prueba, HTTP o HTTPS): + +```bash +npx tsx scripts/manage-subscriptions.ts subscription:add SapWebhookApiTest https://192.168.1.166:3000/webhooks/sap +npx tsx scripts/manage-subscriptions.ts filter:add SapWebhookApiTest Orders A 17 +``` + +Otros comandos disponibles: `subscription:list`, `subscription:update-url + `, `subscription:pause`/`subscription:resume`, +`filter:list`, `filter:remove `. + +```bash +npm run run-once # una sola pasada, útil para probar +npm run start # corre en loop +``` + +## Logs + +Cada tick escribe a `logs/dispatcher-YYYY-MM-DD.log` (uno por día) además +de la consola — línea simple ` [NIVEL] mensaje`, legible +por cualquier visualizador genérico (`tail -f logs/dispatcher-*.log`, +`lnav`, etc.). Siempre deja al menos una línea de resumen por tick +(`Tick: N evento(s) nuevo(s), M pendiente(s) de entrega`), incluso sin +actividad — para poder confirmar que el proceso sigue vivo. No versionado +(`.gitignore`). + +## Estructura + +``` +docs/DISENO.md # diseño completo, diccionario de datos, decisiones, diagrama de flujo +sql/transaction-notification/ + sp-webhook-capture-event-mssql.sql # SP_Webhook_CaptureEvent — la lógica real (SQL Server) + alter_sbo_sp_posttransactionnotice.sql # SBO_SP_PostTransactionNotice — un EXEC al de arriba + sp-webhook-capture-event-hana.sql # ídem, HANA/SQLScript — no verificado, ver aviso en el archivo + recreate_sbo_sp_posttransactionnotice_hana.sql # ídem, HANA (DROP+CREATE, no ALTER) +scripts/ + manage-subscriptions.ts # CLI: alta/listado de suscripciones y filtros (@OEVC/@EVC1) + export-table-json.ts # regenera json/user-tables/*.json a partir de table-definitions.ts +json/user-tables/ # body real de UserTablesMD/UserFieldsMD por tabla, para revisar o postear a mano + OEVC.json / EVC1.json / OEVN.json / EVN1.json +src/ + config.ts # env: Service Layer, poll interval, retry limit + service-layer-client.ts # cliente mínimo del Service Layer (Login/GET/POST/PATCH/DELETE) + lib/ + codes.ts # Code sintético (UUID) para filas creadas desde Node + timestamps.ts # formato HHMMSS para U_LastSentTM + cloud-event.ts # arma el payload CloudEvents a partir de una fila de @OEVN + logger.ts # log a consola + archivo (logs/dispatcher-YYYY-MM-DD.log) + mapping/ + object-types.ts # ObjectType -> BizObject + trans-types.ts # TransType -> Operation + provisioning/ + table-definitions.ts # esquema declarativo de las 4 UserTables (única fuente de verdad) + payloads.ts # traduce table-definitions.ts a los bodies reales de Service Layer + user-tables.ts # crea UserTablesMD/UserFieldsMD si no existen (idempotente) + run-provision.ts # CLI: npm run provision + subscriptions/ + subscriptions-repository.ts # acceso a @OEVC/@EVC1 (suscripciones activas y filtros) + dispatcher/ + oevn-repository.ts # acceso a @OEVN (leer/borrar/actualizar estado) + evn1-repository.ts # acceso a @EVN1 (crear, listar pendientes, marcar entrega/fallo) + send-webhook.ts # POST del batch de CloudEvents, firma HMAC opcional + own-table-guard.ts # detecta ecos de escrituras propias (ver docs/DISENO.md, hallazgo 5) + process-tick.ts # orquesta un tick completo + index.ts # cron loop / --once +``` + +## Stack + +Mismo criterio que [`sap-intercompany-worker`](../sap-intercompany-worker): +script plano en TypeScript (ESM) con `tsx`, sin framework, sin base de +datos propia — todo el estado vive en las 4 UserTables dentro de SAP. + +## Probar sin una instancia real de sap-webhook-api + +[`sap-test-legacy-webhook`](../sap-test-legacy-webhook) es un receptor +mínimo (sin framework, sin autenticación por ahora, HTTP o HTTPS con +certificado autofirmado) pensado específicamente para probar este +proyecto de punta a punta sin depender de `sap-webhook-api`. Guarda cada +CloudEvent recibido en `data/events/.json`. Usado para confirmar el +flujo completo (captura → dispatcher → entrega) y, por separado, que +HTTPS + certificado autofirmado funciona bien del lado del dispatcher. + +## Simplificaciones deliberadas de esta v1 + +- Solo `AuthType` `N` (sin firma) y `H` (HMAC) — `Basic` y `OAuth` no están + implementados. +- Solo se maneja una clave simple en el evento (`FieldsInKe=1`, típicamente + `DocEntry`) — claves compuestas (ej. `CardCode`+`ItemCode`) no se arman + todavía en `data` del CloudEvent. +- No hay CLI para administrar `RETRY_LIMIT`/`State` de forma más fina que + pausar/reactivar toda la suscripción. diff --git a/docs/DISENO.md b/docs/DISENO.md new file mode 100644 index 0000000..783917d --- /dev/null +++ b/docs/DISENO.md @@ -0,0 +1,792 @@ +# sap-legacy-webhook-emulator — Diseño, acuerdos y diccionario de datos + +> Estado: **flujo completo probado de punta a punta contra un SAP Business +> One sobre HANA real** — provisioning, captura (`ObjectType=4`/`Items`), +> dispatcher, entrega por HTTP y por HTTPS con certificado autofirmado +> (ver "Estado de la implementación" y "Plan de prueba" al final). Falta: +> reinstalar y verificar el filtro por suscripción activa del SP más +> reciente (ver "Pendientes", ítem 0), y probar el camino de reintento/falla. +> Este documento recoge lo acordado para poder retomar la conversación de +> diseño o la implementación sin perder contexto. + +## Objetivo + +`sap-webhook-api` recibe los eventos del **Webhook Messenger nativo** de SAP +Business One, disponible desde **FP2605**. Hay clientes que todavía no están +en esa versión y no la van a tener en el corto plazo, pero sí necesitan que +los eventos de negocio (creación de una orden, un asiento, etc.) lleguen al +mismo lugar. + +`sap-legacy-webhook-emulator` es un componente nuevo que **reconstruye ese +mecanismo por fuera de SAP**, usando datos y estructuras equivalentes a las +que usa la feature nativa, y termina emitiendo **el mismo payload +CloudEvents, contra el mismo endpoint** (`POST /webhooks/sap`) que ya +implementa `sap-webhook-api` (`sap-webhook-api/src/webhooks/webhooks.controller.ts:84-108`). +Por diseño, **`sap-webhook-api` no necesita ningún cambio** — para él, este +componente es indistinguible del Webhook Messenger real. + +## Por qué se descartaron otras alternativas + +- **Polling directo a Service Layer** (sin tocar SQL) quedó como alternativa + de reserva, no como mecanismo principal: más latencia, hay que mantener un + watermark por `BusinessObject`+compañía, y no detecta bien los `Delete`. +- Se prefirió capturar el evento en el momento real de la transacción usando + un mecanismo del lado de la base de datos de SAP. + +## Decisiones de arquitectura acordadas + +### 1. `SBO_SP_PostTransactionNotice`, no `SBO_SP_TransactionNotification` + +SAP ofrece dos stored procedures "hook": + +- `SBO_SP_TransactionNotification` — **síncrono**, corre *antes* del commit, + puede devolver un error y SAP hace *rollback* de la transacción real del + usuario si tarda o falla. +- `SBO_SP_PostTransactionNotice` — corre *después* del commit, de forma + asíncrona respecto a la transacción del usuario. No puede bloquear ni + revertir nada. + +Se eligió la variante **Post** para no arriesgar nunca una transacción real +de un cliente por un problema de nuestro lado. + +### 2. Las 4 tablas se crean como `UserTablesMD` (DI API / Service Layer), no como tablas SQL crudas + +Se evaluaron dos caminos para crear las tablas `@OEVC`/`@EVC1`/`@OEVN`/`@EVN1`: + +- Tablas SQL propias (`CREATE TABLE` directo) — permitía replicar el esquema + real de SAP campo por campo, pero **no queda registrado en el catálogo de + SAP (`OUTB`)** y es una customización por fuera del framework soportado. +- **`UserTablesMD` vía DI API/Service Layer** (elegido) — es el mecanismo + oficialmente soportado por SAP para extender el modelo de datos de la + company DB, evita problemas de soporte/upgrade a futuro. + +Esto tiene dos implicancias de diseño que hay que respetar: + +- Toda `UserTablesMD` obliga una clave primaria **`Code`** + columna + **`Name`**, más columnas de auditoría que SAP llena solo (`CreateDate`, + `CreateTime`, `UpdateDate`, `UpdateTime` — pero sin milisegundos, a + diferencia del `CreateTime` de 9 dígitos del `OEVN` nativo real). Cualquier + columna propia (`BizObject`, `TransType`, `Status`, etc.) se registra vía + `UserFieldsMD` y **SAP le antepone `U_`** automáticamente (`U_BizObject`, + no `BizObject`). +- Un `UserTable` **no soporta clave primaria compuesta** — solo `Code` simple. + `@EVC1` (PK real: `WebhookID`+`BizObject`+`TransType`) y `@EVN1` (PK real: + `EventID`+`WebhookID`) necesitan una estrategia de `Code` sintético (ver + "Pendientes" más abajo). +- `TableType` propuesto para las 4 tablas: `bott_NoObject` (sin + header/líneas — no estamos modelando documentos). + +### 3. El SP inserta en `@OEVN` solo si hay una suscripción activa esperando ese evento + +**Revisión de la decisión original** (que decía "el SP inserta siempre, +sin condiciones, sin joins"): con el volumen real que generó el primer +ambiente de prueba (cientos de eventos por minuto, la enorme mayoría sin +ninguna suscripción real interesada), quedó claro que insertar todo y +descartar después ensucia `@OEVN` innecesariamente. El SP ahora hace un +`EXISTS` contra `@EVC1`+`@OEVC` (`ObjectType`+`TransType` exactos, con +`State='A'`) antes de insertar. Esto **no reintroduce** el problema que la +decisión original evitaba (duplicar una lista de negocio en dos +lenguajes): el `EXISTS` lee la misma tabla de configuración que ya es la +fuente de verdad para el dispatcher — no hay una segunda copia que +mantener sincronizada, es la config real, en vivo. Sigue siendo cierto que +el SP no resuelve `BizObject`/`Operation` (eso lo sigue haciendo el +dispatcher al leer, ver decisión 4) y no crea filas en `@EVN1` — solo +decide si vale la pena insertar el evento crudo o no. + +Además, el SP sigue chequeando el patrón de eco de escrituras propias (ver +más abajo) antes que nada — un eco nunca va a matchear ningún `EVC1` real +(su `ObjectType` es un string compuesto tipo `-3\t@OEVN`, no un código +positivo real), así que en la práctica el chequeo de suscripción ya lo +filtraría solo, pero se mantiene el chequeo explícito por claridad y para +no depender de esa coincidencia. + +Toda la demás lógica de negocio (crear las filas en `@EVN1`, armar y mandar +el `POST` con el CloudEvent, actualizar los estados) vive en un proceso +Node/TypeScript aparte, **hablando siempre por Service Layer** (nunca SQL +directo) — así el mismo código sirve para clientes sobre SQL Server y sobre +HANA sin diferenciar motor. Los únicos puntos que sí son SQL crudo (uno por +motor) son los dos SP, porque corren dentro del motor de base y no pueden +llamar a Service Layer desde ahí. + +El dispatcher igual conserva su lógica de descarte (`ObjectType` sin +mapear a `BizObject`, o sin suscripción activa) como red de seguridad ante +condiciones de carrera — ej. si se borra un filtro justo entre que el SP +lo insertó y el dispatcher lo procesa — aunque en el camino normal ya no +debería activarse casi nunca. + +Además, la lógica de captura no vive directamente en el cuerpo de +`SBO_SP_PostTransactionNotice` — ese es un hook **compartido** de SAP (SAP +lo crea vacío por cada compañía nueva, y ahí puede convivir código de otros +add-ons instalados en el mismo cliente). Para que la nuestra sea fácil de +identificar, mantener o remover sin tocar el de otros, vive en un +procedimiento propio con nombre descriptivo — +`SP_Webhook_CaptureEvent` (`sql/transaction-notification/sp-webhook-capture-event-mssql.sql`) +— y el hook (`sql/transaction-notification/alter_sbo_sp_posttransactionnotice.sql`) queda reducido a un +único `EXEC` hacia ese procedimiento. + +Ese proceso hace polling de `@OEVN` con `Status='N'` (mismo patrón que ya +usa `sap-intercompany-worker` con `node-cron` sobre `webhook_events`). + +### 4. `@OEVN` no persiste `BizObject` ni `Operation` — se derivan al leer + +El SP recibe `object_type` y `transaction_type` tal cual se los da SAP y los +guarda sin traducir. `BizObject` (mapeo de `ObjectType`) y `Operation` +(mapeo de `TransType`) son valores derivados que el dispatcher calcula en el +momento de armar el CloudEvent, usando las tablas de mapeo de este mismo +documento — no hace falta persistirlos ni duplicar el mapeo en SQL. Menos +columnas en el UDT, y el SP se mantiene con un único `INSERT` sin lógica. + +### 5. Estrategia de `Code` definida: siempre sintético, generado con el UUID nativo de cada motor + +La regla es uniforme para las 4 tablas: `Code` **nunca** es el identificador +de negocio real, siempre es un UUID generado con la función nativa del motor +— `NEWID()` en SQL Server, `SELECT SYSUUID FROM DUMMY` en HANA, y +`crypto.randomUUID()` en Node para las filas que crea el dispatcher +(`@EVC1`/`@EVN1`) — salvo `@OEVC`, donde `Code` = `WebhookID` directamente +(es un id corto elegido por un administrador al crear la suscripción, no +hay razón para generarle un UUID). Los identificadores de negocio reales +(`EventID` de un evento, `WebhookID` de una suscripción, `BizObject`+ +`TransType` de un filtro) viven siempre como columnas `U_` propias, nunca +codificados dentro de `Code`. El `id` del CloudEvent que se despacha es +directamente el `Code` de la fila de `@OEVN` — no hace falta un campo +`EventID` aparte en esa tabla. Los campos `U_` que referencian el `Code` de +otra tabla (`EVN1.U_EventID`, `EVN1.U_WebhookID`, `EVC1.U_WebhookID`) se +dimensionan en 40 caracteres para dar margen sobre el largo de un UUID +(36 caracteres con guiones). + +**Corolario confirmado contra Service Layer real** (ver "Hallazgos +confirmados...", hallazgo 4): `Name` también debe ser único en una +UserTable, igual que `Code` — por eso `Name` siempre usa el mismo UUID que +`Code` (nunca un valor que se repita entre filas, como el `ObjectType`). + +## Nomenclatura + +| Término | Significado | +|---|---| +| `ObjectType` | Código numérico interno de SAP para un tipo de objeto de negocio (ej. `17`). Viene tal cual del parámetro `object_type` que recibe el SP. Enumeración oficial: `BoObjectTypes` del SDK de SAP B1. | +| `BizObject` | Nombre del objeto de negocio, el mismo que usa Service Layer/DI API para referirse a esa entidad (ej. `Orders`, `BusinessPartners`). Es la contraparte legible de `ObjectType`. Regla de mapeo confirmada: el nombre del member de `BoObjectTypes` sin el prefijo `o` (`oOrders` → `Orders`). | +| `TransType` | Código de una letra para el tipo de transacción sobre el documento (`A`=Created, `U`=Updated, `D`=Deleted, `C`=Canceled, `O`=Reopened, `L`=Closed). | +| `Operation` | Nombre completo de la operación (`Created`, `Updated`, etc.), sale de mapear `TransType`. Se usa para construir el `type` del CloudEvent. | +| `EventID` | Identificador único de un evento capturado (PK de `OEVN` nativo). En nuestra implementación, sujeto a la longitud real de `Code` en `UserTablesMD` (ver "Pendientes"). | +| `WebhookID` | Identificador de una suscripción (PK de `OEVC` nativo). | +| CloudEvent | Estándar ([cloudevents.io](https://cloudevents.io/)) que usa el payload real del Webhook Messenger de SAP, y el que ya implementa `sap-webhook-api`. `type` tiene la forma `sap.b1...v1` (ej. `sap.b1.Orders.Created.v1`). | + +## Diccionario de datos + +> La primera tabla de cada sección es el esquema **real** de SAP, documentado +> por el usuario a partir de la definición nativa (`OEVC`/`EVC1`/`OEVN`/`EVN1`). +> Debajo, el esquema **que efectivamente implementamos** como `UserTablesMD` +> (`@OEVC`/`@EVC1`/`@OEVN`/`@EVN1`), aplicando las decisiones (2), (4) y (5) +> de arriba: `Code`/`Name` obligatorios y generados por nosotros (no son el +> identificador real de negocio, salvo en `@OEVC`), prefijo `U_` en cada +> campo propio, sin columnas derivadas ni irrelevantes para el emulador. + +### `OEVN` — Event Notification (evento capturado) + +Esquema real de SAP: + +| Campo | Tipo | Tamaño | Valores / notas | +|---|---|---|---| +| `EventID` | nVarChar | 50 | **PK única.** | +| `SourceDB` | nVarChar | 100 | | +| `Status` | VarChar | 1 | `N`=New (default), `F`=Failed, `P`=Partially Delivered, `D`=Delivered, `A`=Archived. Estado agregado del evento sobre todas sus filas en `EVN1`. | +| `ObjectType` | nVarChar | 30 | Código numérico **como texto** (ej. `'17'`). | +| `BizObject` | nVarChar | 64 | Ej. `'Orders'`. | +| `TransType` | VarChar | 1 | Ver tabla de `TransType` más abajo. | +| `Operation` | nVarChar | 64 | Ej. `'Created'`. | +| `FieldsInKe` | Int | 11 | Cantidad de campos clave (en nuestro caso: 1, `DocEntry`). | +| `FieldNames` | nVarChar | 254 | Nombre del/los campo(s) clave, ej. `'DocEntry'`. | +| `FieldValue` | nVarChar | 254 | Valor del/los campo(s) clave, ej. `'295'`. | +| `ReplayStat` | VarChar | 1 | `N`=None (default), `R`=Replaying, `C`=Completed. | +| `UserID` | nVarChar | 25 | Usuario SAP que disparó la transacción. | +| `SAPPassprt` | Text | 16 | Extended SAP Passport. | +| `CreateDate` | Date | 8 | | +| `CreateTime` | Int | 11 | Formato `HHMMSSmmm` (9 dígitos, con milisegundos) en el `OEVN` nativo real — confirmado con un `INSERT` capturado por trace de SQL Server (`125318067` = `12:53:18.067`). | +| `UpdateDate` | Date | 8 | | +| `UpdateTime` | Int | 11 | | + +Esquema de `@OEVN` (`UserTablesMD`, `TableType=bott_NoObject`): + +| Columna física | Origen | Notas | +|---|---|---| +| `Code` | generado por nosotros | 20 caracteres hex al azar. Es el `id` que se manda en el CloudEvent — no hace falta un campo `EventID` aparte. | +| `Name` | generado por nosotros | Label legible, `-` (ej. `17-A`), solo para lectura humana en el cliente SAP. | +| `U_SourceDB` | Alpha(100) | | +| `U_Status` | Alpha(1), default `N` | mismos valores que el real. | +| `U_ObjectType` | Alpha(30) | | +| `U_TransType` | Alpha(1) | | +| `U_FieldsInKe` | Numeric | | +| `U_FieldNames` | Alpha(254) | | +| `U_FieldValue` | Alpha(254) | | +| `U_ReplayStat` | Alpha(1), default `N` | | +| `U_UserID` | Alpha(25) | Definido pero **sin poblar** — ver decisión/pendiente sobre `U_UserID` más abajo. | + +No se incluyen `BizObject`/`Operation` (ver decisión 4) ni `SAPPassprt` +(específico de SAP, sin uso para el emulador). `CreateDate`/`CreateTime`/ +`UpdateDate`/`UpdateTime` los gestiona SAP automáticamente en todo +`UserTable` (sin milisegundos — no hace falta replicarlo a mano). + +### `EVN1` — Event Notification Details (estado de entrega por suscripción) + +Esquema real de SAP: + +| Campo | Tipo | Tamaño | Valores / notas | +|---|---|---|---| +| `EventID` | nVarChar | 50 | **PK compuesta** con `WebhookID`. | +| `WebhookID` | nVarChar | 40 | **PK compuesta** con `EventID`. | +| `Status` | VarChar | 1 | `F`=Failed, `D`=Delivered (default). Solo estos dos valores — es el estado puntual de *esta* suscripción para *este* evento (a diferencia del `Status` agregado de `OEVN`, que tiene 5 valores). | +| `Retry` | Int | 11 | Cantidad de reintentos. | +| `ReturnCode` | Int | 11 | Código de error del último intento. | +| `LastSentDT` | Date | 8 | | +| `LastSentTM` | Int | 11 | | + +Esquema de `@EVN1`: + +| Columna física | Origen | Notas | +|---|---|---| +| `Code` | generado por nosotros | sintético (ej. hash corto de `EventID`+`WebhookID`) — la PK compuesta real no es soportada por un `UserTable`. | +| `Name` | generado por nosotros | `/`, legible. | +| `U_EventID` | Alpha(20) | referencia al `Code` de la fila en `@OEVN`. | +| `U_WebhookID` | Alpha(40) | referencia al `Code` de la fila en `@OEVC` (= el `WebhookID` real). | +| `U_Status` | Alpha(1), default `F` | | +| `U_Retry` | Numeric | | +| `U_ReturnCode` | Numeric | | +| `U_LastSentDT` | Date | | +| `U_LastSentTM` | Numeric | | + +### `OEVC` — Event Subscription (suscripción a webhook) + +Esquema real de SAP: + +| Campo | Tipo | Tamaño | Valores / notas | +|---|---|---|---| +| `WebhookID` | nVarChar | 40 | **PK única.** | +| `WebhookURL` | nVarChar | 254 | **UNIQUE.** No puede haber dos suscripciones activas apuntando a la misma URL. | +| `AuthType` | VarChar | 1 | `N`=None, `B`=Basic, `O`=OAuth, `H`=HMAC (default). | +| `AuthCred` | Text | 16 | Credenciales de autenticación. | +| `Handshake` | VarChar | 1 | `Y`=Yes (default), `N`=No. | +| `VerifyCert` | VarChar | 1 | `Y`=Yes (default), `N`=No. | +| `State` | VarChar | 1 | `A`=Active (default), `I`=Inactive, `X`=Exceptional (estado al que cae la suscripción tras fallos/timeouts repetidos). | +| `WorkMode` | VarChar | 1 | `S`=Standard (default), `R`=Replay — se conecta con `ReplayStat` de `OEVN`: en modo Replay se reprocesan eventos viejos para esta suscripción. | +| `CreateDate`/`CreateTime` | Date/Int | 8/11 | | +| `UpdateDate`/`UpdateTime` | Date/Int | 8/11 | | +| `LastSentDT`/`LastSentTM` | Date/Int | 8/11 | | +| `LastErrMsg` | nVarChar | 254 | | +| `UserSign` | Int | 6 | | +| `UserSign2` | Int | 6 | Usuario que actualizó. | +| `DataVers` | Int | 11 | Default `1`. | +| `LogInstanc` | Int | 11 | Default `0`. | + +Esquema de `@OEVC`: + +| Columna física | Origen | Notas | +|---|---|---| +| `Code` | = `WebhookID` | única excepción a "Code siempre sintético": es un id corto elegido por un administrador, no hace falta ofuscarlo. | +| `Name` | = `WebhookID` | | +| `U_WebhookURL` | Alpha(254) | | +| `U_AuthType` | Alpha(1), default `H` | | +| `U_AuthCred` | Alpha(254) | | +| `U_Handshake` | Alpha(1), default `Y` | | +| `U_VerifyCert` | Alpha(1), default `Y` | | +| `U_State` | Alpha(1), default `A` | | +| `U_WorkMode` | Alpha(1), default `S` | | +| `U_LastSentDT` | Date | | +| `U_LastSentTM` | Numeric | | +| `U_LastErrMsg` | Alpha(254) | | + +`UserSign`/`UserSign2`/`DataVers`/`LogInstanc` los gestiona SAP +automáticamente en todo `UserTable` — no se declaran a mano. + +### `EVC1` — Event Subscription Details (filtro de objeto/transacción por suscripción) + +Esquema real de SAP: + +| Campo | Tipo | Tamaño | Valores / notas | +|---|---|---|---| +| `WebhookID` | nVarChar | 40 | **PK compuesta** con `BizObject`+`TransType`. | +| `BizObject` | nVarChar | 64 | **PK compuesta.** | +| `TransType` | VarChar | 1 | **PK compuesta.** Ver tabla de valores abajo. | +| `ObjectType` | nVarChar | 30 | | +| `LogInstanc` | Int | 11 | Default `0`. | + +Esquema de `@EVC1`: + +| Columna física | Origen | Notas | +|---|---|---| +| `Code` | generado por nosotros | sintético (hash corto de `WebhookID`+`BizObject`+`TransType`). | +| `Name` | generado por nosotros | `//`, legible. | +| `U_WebhookID` | Alpha(40) | referencia al `Code` de la fila en `@OEVC`. | +| `U_BizObject` | Alpha(64) | | +| `U_TransType` | Alpha(1) | | +| `U_ObjectType` | Alpha(30) | | + +### Valores de `TransType` / `Operation` + +| `TransType` | `Operation` | +|---|---| +| `A` | Created | +| `U` | Updated | +| `D` | Deleted | +| `C` | Canceled | +| `O` | Reopened | +| `L` | Closed | + +### Mapeo `ObjectType` → `BizObject` (subconjunto relevante) + +Regla general: nombre del member de `BoObjectTypes` sin el prefijo `o`. +Confirmado con el `INSERT` real capturado por trace (`ObjectType='17'` → +`BizObject='Orders'`). El feature nativo de Webhooks de SAP solo soporta un +subconjunto curado de `BusinessObject`, no los ~150 `ObjectType` que existen +en total — se arranca con los que ya usa `sap-intercompany-worker` más los +de documentos de venta/compra más comunes. + +| `ObjectType` | `BizObject` | +|---|---| +| 1 | ChartOfAccounts | +| 2 | BusinessPartners | +| 4 | Items — ✅ confirmado con captura y entrega real (`PATCH` a un Item, `sap.b1.Items.Updated.v1` entregado de punta a punta) | +| 13 | Invoices | +| 14 | CreditNotes | +| 15 | DeliveryNotes | +| 16 | Returns | +| 17 | Orders | +| 18 | PurchaseInvoices | +| 19 | PurchaseCreditNotes | +| 20 | PurchaseDeliveryNotes | +| 21 | PurchaseReturns | +| 22 | PurchaseOrders | +| 23 | Quotations | +| 24 | IncomingPayments | +| 30 | JournalEntries | +| 46 | VendorPayments | +| 53 | SalesPersons | +| 67 | StockTransfer | +| 202 | ProductionOrders | + +Fuente completa (todos los `ObjectType` de `BoObjectTypes`, no solo los +relevantes acá): documentación oficial del SDK de SAP Business One +(`help.sap.com`, enumeración `BoObjectTypes`), contrastada con el listado +provisto por el usuario — coinciden. + +## Dispatcher — lenguaje, arquitectura y flujo + +### Lenguaje y arquitectura + +Mismo criterio que el resto del proyecto y que `sap-intercompany-worker`: +**TypeScript (ESM) ejecutado con `tsx`, sin framework, sin servidor HTTP +propio.** `node-cron` para el loop periódico (`POLL_INTERVAL_SECONDS`, +mismo patrón que ya está en `src/config.ts`). Toda la comunicación con SAP +es por Service Layer, con el `ServiceLayerClient` ya escrito +(`src/service-layer-client.ts`) — no hay acceso a SQL desde el dispatcher +(decisión 3), y **no hay base de datos propia**: todo el estado (eventos +pendientes, suscripciones, estado de entrega) vive en las 4 UserTables +dentro de SAP. Esto es más simple que `sap-intercompany-worker` (que sí +tiene Postgres/Prisma) porque acá no hace falta persistir nada del lado +nuestro — el "outbox" ya es SAP mismo. + +### Diagrama de componentes + +```mermaid +flowchart LR + SAP["Transacción real en SAP B1"] -->|commit| Hook["SBO_SP_PostTransactionNotice"] + Hook --> Capture["SP_Webhook_CaptureEvent"] + Capture -->|INSERT| OEVN[("@OEVN")] + + subgraph Dispatcher["dispatcher (Node + node-cron, cada POLL_INTERVAL_SECONDS)"] + Poll["1. Leer @OEVN Status=N"] + Match["2. Resolver BizObject/Operation
y matchear @OEVC + @EVC1"] + Track["3. Crear/actualizar filas en @EVN1"] + Send["4. Armar CloudEvents,
agrupar por WebhookURL, POST"] + Update["5. Actualizar @EVN1 y @OEVN"] + Poll --> Match --> Track --> Send --> Update + end + + OEVN -. Service Layer .-> Poll + OEVC[("@OEVC")] -. Service Layer .-> Match + EVC1[("@EVC1")] -. Service Layer .-> Match + Track -. Service Layer .-> EVN1[("@EVN1")] + Update -. Service Layer .-> EVN1 + Update -. Service Layer .-> OEVN + Send --> WebhookAPI["sap-webhook-api
POST /webhooks/sap"] +``` + +### Secuencia por evento (un tick del dispatcher) + +```mermaid +sequenceDiagram + participant O as "@OEVN (Status=N)" + participant D as Dispatcher + participant C as "@OEVC / @EVC1" + participant N as "@EVN1" + participant W as sap-webhook-api + + D->>O: GET ?$filter=U_Status eq 'N' + O-->>D: filas pendientes + loop por cada fila + D->>D: ObjectType -> BizObject (mapping) + alt ObjectType desconocido + D->>O: DELETE la fila (no nos interesa) + else + D->>C: matchear BizObject+TransType contra suscripciones activas + alt sin suscripción activa + D->>O: DELETE la fila (nadie la espera) + else hay 1+ suscripciones + D->>N: crear fila (EventID, WebhookID, Status='F') si no existe + D->>D: armar CloudEvent, agrupar por WebhookURL + D->>W: POST (batch por WebhookURL) + W-->>D: 200 / error + D->>N: actualizar Status/Retry/ReturnCode/LastSent* + D->>O: recalcular Status agregado (D/P/F) + end + end + end +``` + +### Decisiones a confirmar antes de implementar + +1. **Batching por `WebhookURL`**: si dos eventos distintos matchean la + misma suscripción en el mismo tick, se agrupan en **un solo `POST`** + (array de CloudEvents) — replica el comportamiento real que ya vimos + (5 eventos atascados llegaron juntos en un solo `POST` cuando se + destrabó el bug de `DATEFORMAT`). +2. **Descarte silencioso**: si un `ObjectType` no mapea a ningún `BizObject` + conocido, o mapea pero no hay ninguna suscripción activa para ese + `BizObject`+`TransType`, la fila de `@OEVN` se **borra** (no se archiva) + — mantiene la tabla liviana. Se puede loguear a consola para + diagnóstico, pero no queda rastro persistente en SAP. +3. **Reintentos**: `@EVN1.U_Retry` se incrementa en cada intento fallido. + Falta definir un límite (¿cuántos reintentos antes de dar por perdido un + evento para esa suscripción?) y si al agotarlo hay que pasar `@OEVC` a + `State='X'` (Exceptional) — mismo comportamiento que documentamos para + el Messenger real. +4. **Idempotencia**: antes de crear una fila en `@EVN1`, hay que chequear + que no exista ya para ese `EventID`+`WebhookID` (por si el dispatcher se + interrumpió a mitad de un tick anterior) — evita mandar el mismo evento + dos veces a la misma suscripción. + +## Pendientes / a verificar antes o durante la implementación + +0. ⚠️ **Filtro por suscripción activa (decisión 3, revisión) escrito pero + NO reinstalado/verificado todavía** — el `EXISTS` contra `@EVC1`/`@OEVC` + está en los dos SP (`sp-webhook-capture-event-mssql.sql`, + `sp-webhook-capture-event-hana.sql`), pero las pruebas end-to-end que sí + se hicieron (captura y entrega de `Items`/`ObjectType=4`, ver "Plan de + prueba") corrieron contra la versión **anterior** del SP en HANA (solo + con el filtro de eco, sin el `EXISTS`) — nunca se reinstaló la versión + con el filtro de suscripción en el ambiente real. Falta: reinstalar + `sp-webhook-capture-event-hana.sql` (versión actual) y confirmar que + sigue capturando bien, y que ahora *no* inserta nada para un + `ObjectType`/`TransType` sin ninguna suscripción activa (en vez de + insertarlo y que el dispatcher lo borre después). +1. **`IncomingPayments` vs `VendorPayments`**: la enumeración `BoObjectTypes` + describe ambos (`24` y `46`) como "Payments object" — falta confirmar si + el `BizObject` real difiere entre los dos como se asume en la tabla de + arriba, cuando probemos ese caso puntual. +2. ✅ **Provisioning probado contra un Service Layer real** (SAP Business + One sobre HANA) — ver "Hallazgos confirmados contra Service Layer real" + más abajo. Falta todavía probar la inserción de una fila real con + `Code`=UUID (36 caracteres) — el provisioning solo crea metadata de + tabla/campo, no probó longitud de dato en una fila. +3. ✅ **Los dos SP de HANA verificados** — `sp-webhook-capture-event-hana.sql` + y `recreate_sbo_sp_posttransactionnotice_hana.sql` (el hook) están + instalados y funcionando contra un SAP Business One sobre HANA real, + incluida la sintaxis del `EXIT HANDLER`/`::SQL_ERROR_CODE` que estaba + marcada como no confirmada. Falta todavía confirmar con una transacción + real en SAP que `@OEVN` se llena sola (el hook está instalado, pero no + se disparó todavía con un documento real). +4. ✅ **Confirmado (no solo teórico)**: `CURRENT_USER` en HANA da el + usuario de la conexión (una cuenta técnica), no el usuario real de SAP + que hizo la transacción — el hook no tiene ese dato entre sus 5 + parámetros. Mismo límite aplica a `SUSER_SNAME()` en SQL Server, por la + misma razón. **Decisión**: `U_UserID` se deja definido en las 4 tablas + (no se saca del esquema, puede servir a futuro si aparece otra forma de + obtenerlo), pero **no se completa** en el `INSERT` de ninguno de los dos + SP, y el dispatcher (Node) no lo lee en ningún lado. + **A investigar más adelante** (no bloquea nada hoy): el usuario planteó + que versiones más nuevas de SAP podrían pasar el usuario SAP real como + parámetro del hook — una búsqueda rápida no encontró confirmación de un + parámetro nuevo agregado a `SBO_SP_TransactionNotification`/ + `SBO_SP_PostTransactionNotice` en ninguna versión; lo que sí aparece + documentado es la forma histórica indirecta (join contra tablas de log + como `ACRD`/`ADOC` usando `UserSign`, no un parámetro del hook). Falta + confirmar contra la versión de SAP específica del cliente cuando se + retome este punto. + +## Estado de la implementación + +- ✅ `docs/DISENO.md` (este documento). +- ✅ Scaffold del proyecto (`package.json`, `tsconfig.json`, config, cliente + de Service Layer) — sigue el mismo patrón que `sap-intercompany-worker` + (ESM + `tsx`, sin framework). +- ✅ Mapeos `ObjectType`→`BizObject` y `TransType`→`Operation` + (`src/mapping/`). +- ✅ Provisioning de las 4 `UserTablesMD` vía Service Layer + (`src/provisioning/`, `npm run provision`), con los bodies reales + también exportados a `json/user-tables/*.json` + (`scripts/export-table-json.ts`) para revisar o postear a mano. +- ✅ Lógica de captura para SQL Server, separada en dos archivos: + `SP_Webhook_CaptureEvent` (`sql/transaction-notification/sp-webhook-capture-event-mssql.sql`, + el único que inserta en `@OEVN`) y el hook `SBO_SP_PostTransactionNotice` + (`sql/transaction-notification/alter_sbo_sp_posttransactionnotice.sql`, reducido a un `EXEC`). +- ✅ CLI de suscripciones (`scripts/manage-subscriptions.ts`) — alta/listado + de `@OEVC` (suscripciones) y `@EVC1` (filtros), pausar/reactivar. +- ✅ Dispatcher (`src/dispatcher/`, `src/subscriptions/`, `src/lib/cloud-event.ts`, + `src/lib/timestamps.ts`, `src/index.ts`) — implementa el flujo completo + del diagrama: registra eventos nuevos, crea `@EVN1`, agrupa por + suscripción, hace el `POST` batch (con HMAC opcional), actualiza estados, + reintenta con límite (`RETRY_LIMIT`) y escala la suscripción a + `State='X'` al agotarlo. Simplificaciones de esta v1 documentadas en el + `README.md` (sin `Basic`/`OAuth`, sin claves compuestas en el evento). +- ✅ Los dos SP de captura para HANA + (`sql/transaction-notification/sp-webhook-capture-event-hana.sql` y + `recreate_sbo_sp_posttransactionnotice_hana.sql`) — instalados y + verificados contra un SAP Business One sobre HANA real. +- ✅ Captura confirmada con una transacción real (`PATCH` a un Item) — ver + "Hallazgos confirmados contra Service Layer real", hallazgo 3. +- ✅ Filtro anti-eco (loop de escrituras propias) implementado en los dos + SP y en el dispatcher (`own-table-guard.ts`), verificado contra HANA + real — ver hallazgo 5. +- ⚠️ Filtro por suscripción activa (`EXISTS` contra `@EVC1`/`@OEVC` en el + SP, decisión 3 revisada) — escrito en los dos SP, pero **no reinstalado + ni verificado** contra el ambiente real todavía. Ver "Pendientes", ítem 0. +- ⬜ Limpiar por SQL directo las filas `-3\t@OEVN` que quedaron grabadas + antes de este fix (no vía Service Layer, para no generar un eco nuevo). +- ✅ Dispatcher probado end-to-end contra ese ambiente real (suscripción + + filtro reales, `npm run run-once`) — encontró y corrigió el hallazgo 6 + (paginación de Service Layer) y el hallazgo 7 (tamaño de campos + `Numeric`). +- ✅ **`ObjectType='4'` (`Items`) confirmado real** — capturado, matcheado + contra una suscripción real, entregado por `POST` + (`sap.b1.Items.Updated.v1`), y marcado `Status='D'` en `@OEVN`/`@EVN1` + con `U_LastSentDT`/`U_LastSentTM` completos. `ObjectType='31'` (que se + había usado provisoriamente como `ItemWarehouseInfo` para la primera + prueba) se sacó del mapeo — no se confirmó a qué `BizObject` real + corresponde, no se sigue usando. +- ✅ **Entrega confirmada por HTTPS con certificado autofirmado, sin + autenticación** — usando [`sap-test-legacy-webhook`](../sap-test-legacy-webhook) + (proyecto nuevo, aparte, pensado como receptor de prueba para no + depender de una instancia real de `sap-webhook-api`) en modo `https` + (`npm run start:https`, certificado autofirmado en `certs/`). Confirma + que el `401` reportado en otra prueba (contra un destino real distinto, + no identificado) era autenticación faltante del lado de ese destino, no + un problema de HTTPS/certificado de nuestro lado — `send-webhook.ts` ya + tenía `rejectUnauthorized: false` desde el diseño original, sin cambios. +- ✅ **Logging a archivo** (`src/lib/logger.ts`) — cada tick escribe a + `logs/dispatcher-YYYY-MM-DD.log` además de consola, con un resumen + siempre presente (`Tick: N evento(s) nuevo(s), M pendiente(s) de + entrega`) para poder confirmar actividad aunque no haya nada que hacer. +- ✅ CLI de suscripciones ampliado: `subscription:update-url` (cambiar la + URL sin recrear la suscripción) y `filter:remove` agregados a + `scripts/manage-subscriptions.ts`. +- ⬜ Camino de falla (reintentos, `State='X'` al agotar `RETRY_LIMIT`) — + todavía no probado contra un destino real inalcanzable. + +## Hallazgos confirmados contra Service Layer real + +Al correr `npm run provision` contra el primer ambiente real (SAP Business +One sobre HANA, `192.168.1.175`) aparecieron dos comportamientos de +Service Layer que no estaban documentados en ningún lado consultado, y que +el código original asumía mal. Los dos ya están corregidos: + +1. **`UserTablesMD.TableDescription` tiene un máximo de 30 caracteres.** + Confirmado por binary search contra el servidor real (30 pasa, 31 falla + con `HTTP 400` / `CServiceData::SetPropertyValueString failed; Value + too long`). Las descripciones de las 4 tablas en `table-definitions.ts` + se acortaron para entrar cómodas (13 a 27 caracteres). +2. **`UserFieldsMD.TableName` necesita el nombre físico con `@`, pero + `UserTablesMD.TableName` lo necesita SIN `@`.** Es decir, para crear la + tabla se manda `TableName: "OEVN"`, pero para crear un campo sobre esa + misma tabla hay que mandar `TableName: "@OEVN"` — si no, Service Layer + responde `Table not found (ODBC -2004)` aunque la tabla exista. No es + una inconsistencia nuestra, es así como lo expone la API. Corregido en + `toUserFieldPayload` (`src/provisioning/payloads.ts`). +3. **El EntitySet para leer/escribir FILAS de un UserTable es + `U_`, no ``.** Es decir: la *metadata* de la tabla + se administra en `/UserTablesMD`/`/UserFieldsMD` con el nombre pelado + (`OEVN`), pero para leer o escribir *datos* hay que pegarle a + `/U_OEVN`, `/U_EVC1`, `/U_OEVC`, `/U_EVN1` — `GET /OEVN` responde + `400 Unrecognized resource path`, aunque la tabla exista y tenga datos + (confirmado comparando contra un `SELECT * FROM "@OEVN"` directo en + HANA, que sí mostraba la fila). Se centralizó en una sola función + (`userTableEntity`, `src/lib/service-layer-paths.ts`) para no repetir el + prefijo `U_` como string suelto en cada repositorio — la usan + `oevn-repository.ts`, `evn1-repository.ts`, `subscriptions-repository.ts` + y `manage-subscriptions.ts`. + +**Por qué no se detectó antes de correr contra un servidor real**: el +`ServiceLayerClient` no validaba el status code de `POST`/`PATCH`/`DELETE` +— una creación fallida se veía igual que una exitosa en los logs +(`"Tabla verificada/creada"`). Se corrigió de raíz: `post`/`patch`/`delete` +ahora tiran una excepción si Service Layer responde algo fuera de 2xx +(`ServiceLayerClient.assertOk`, `src/service-layer-client.ts`) — `get` se +deja permisivo a propósito, porque varios call sites usan un 404 como +respuesta válida (chequeos de existencia). Este bug de validación afectaba +potencialmente a todo el proyecto, no solo al provisioning (el dispatcher +también usa `post`/`patch`/`delete` para crear/actualizar `@EVN1`, borrar +`@OEVN`, etc.) — con el fix, cualquier error real de Service Layer en esas +rutas ahora se va a ver como una excepción en los logs del dispatcher, en +vez de pasar desapercibido. + +Verificado tras el fix: las 4 tablas (`@OEVC`=10, `@EVC1`=4, `@OEVN`=9, +`@EVN1`=7 campos) quedaron creadas completas, con los tipos/tamaños +esperados. Y con el hallazgo 3 corregido, se confirmó **captura real +end-to-end**: un `PATCH` a un Item disparó `SBO_SP_PostTransactionNotice` +→ `SP_Webhook_CaptureEvent` → `INSERT` en `@OEVN`, y `listNewEvents()` +(código real del dispatcher, no una prueba suelta) lo encontró vía Service +Layer con todos los campos correctos (`U_ObjectType='31'`, `U_TransType='U'`, +`U_FieldNames='ItemCode\tWhsCode'`, `U_FieldValue='90201011507120002\t100'` +— dos campos clave, tab-delimited, tal como documenta el mecanismo). Nota: +`31` no es el `ObjectType` de `Items` (`4`) — el `PATCH` a un Item +aparentemente disparó el hook para un objeto relacionado a nivel +ítem-almacén, no para `Items` en sí. Falta identificar a qué `BizObject` +corresponde `31` cuando se quiera armar un filtro real para este caso. + +4. **`Name` también debe ser único en una UserTable de SAP, no solo + `Code`.** Confirmado insertando dos filas con `Code` distinto pero el + mismo `Name` contra Service Layer real: la segunda falla con + `ODBC -2035: This entry already exists`. El SP original ponía + `Name := object_type` (ej. `'31'`) — el **primer** evento de un + `ObjectType` se capturaba bien, pero el **segundo** evento del mismo + tipo fallaba en el `INSERT` (tragado en silencio por el + `EXIT HANDLER`/`TRY-CATCH`, así que no se veía ningún error). El usuario + ya conocía este comportamiento de implementaciones previas propias. + Corregido en los dos motores: `Name` ahora usa el mismo UUID que + `Code` (una sola variable, reusada en ambos campos), en vez del + `ObjectType` u otro valor que se repite entre eventos. +5. **Escribir en `@OEVC`/`@EVC1`/`@OEVN`/`@EVN1` vía Service Layer genera + un evento nuevo describiendo esa misma escritura** ("eco"). Confirmado + provocando un `DELETE` real sobre `@OEVN`: generó una fila con + `U_ObjectType = "-3\t@OEVN"` (código negativo + tab + nombre físico de + la tabla — el mecanismo que usa SAP para "operación genérica sobre una + UserTable simple, no un objeto de negocio con código positivo de + `BoObjectTypes`"), `U_TransType = "D"`, `U_FieldNames = "Code"`, + `U_FieldValue = ""`. El usuario confirmó + además que el mismo patrón aparece para `POST`/`PATCH`/`DELETE` por + igual (no solo `DELETE`) — el `ObjectType` especial no depende del + `TransType`. + + **Fix de dos capas**: + 1. **En el origen (los dos SP)**: antes de insertar, se chequea si + `object_type` matchea el patrón `-\t` + — si matchea, directamente **no se inserta nada** (en HANA, el + `INSERT` pasó de `VALUES` a `SELECT ... FROM DUMMY WHERE NOT (...)` + para filtrar sin agregar una rama procedural nueva sin probar; en + SQL Server, un `IF` simple con `LIKE`). Así el eco nunca llega a + `@OEVN` — no hay nada que acumular ni que limpiar. + 2. **Red de seguridad en el dispatcher**: `isEchoOfOwnTable()` + (`src/dispatcher/own-table-guard.ts`), chequeada primero en + `registerNewEvents()` (`process-tick.ts`) — nunca toca (ni resuelve + ni borra) una fila que matchee el patrón. Se mantiene aunque el fix + 1 ya lo previene, porque las filas `-3\t@OEVN` que quedaron grabadas + **antes** de este fix siguen en la tabla — sin este guard, el + dispatcher las trataría como "sin match" y las borraría, generando + un eco nuevo de nuevo (esas filas viejas hay que limpiarlas por SQL + directo, no vía Service Layer, para no perpetuar la cascada). + + ✅ **Verificado end-to-end en HANA real**: se reinstaló + `sp-webhook-capture-event-hana.sql` con el filtro, se hizo un `DELETE` + real vía Service Layer sobre una fila de `@OEVN`, y no apareció ningún + eco nuevo. + +6. **Service Layer pagina las listas de a 20 registros por defecto** + (`odata.nextLink` en la respuesta cuando hay más). Confirmado real: con + más de 20 eventos con `Status='N'` en `@OEVN` (el ambiente de prueba los + genera solo, de forma continua), un `GET` sin paginar devolvía + exactamente 20 y escondía el resto **en silencio** — ningún código + tiraba error, simplemente el dispatcher no veía más allá de la primera + página. Afecta a cualquier lista que pueda superar 20 filas: + `listNewEvents`, `listPending` (`@EVN1`), `listActiveSubscriptions` + (`@OEVC`), `listFilters` (`@EVC1`), y los `list` de + `manage-subscriptions.ts`. **Fix**: `ServiceLayerClient.getAll()` + (`src/service-layer-client.ts`) sigue `odata.nextLink` hasta agotarlo y + junta todas las páginas — reemplaza a `client.get()` en todos los + lugares donde hacía falta la lista completa (no en los que buscan una + fila puntual por clave, como `getEvent`/`findExisting`, no afectados + por paginación). + + Verificado tras el fix con el primer test end-to-end completo del + dispatcher: se levantó un listener HTTP local de prueba, se dio de + alta una suscripción real apuntándole, y `npm run run-once` procesó + correctamente eventos reales (`ObjectType='31'`, mapeado + provisoriamente a `ItemWarehouseInfo`), armó el CloudEvent + (`sap.b1.ItemWarehouseInfo.Updated.v1`) y lo entregó por `POST`. Un + error transitorio de Service Layer (`Property 'U_LastSentTM' of + 'U_EVN1' is invalid`) causó una entrega duplicada en el primer intento + — comportamiento esperado del diseño "al menos una entrega" ante una + falla de tracking, no un bug nuevo. (Ese error puntual con "is invalid" + no se pudo reproducir después y puede ser un síntoma distinto del + hallazgo 7 más abajo, o algo transitorio — no bloqueó nada.) + +7. **Un campo `db_Numeric` sin `Size`/`EditSize` explícito al crearlo queda + limitado a 4 dígitos en la práctica** (aunque la metadata reporte + `Size=6` por default) — valores de 5+ dígitos se pierden **en + silencio** en el `PATCH` (sin error, HTTP 200, pero el campo queda + `null` al releerlo). Confirmado real: probado de forma aislada contra + `U_Retry`, `U_ReturnCode` y `U_LastSentTM` de `@EVN1` — los tres fallan + igual arriba de 4 dígitos, y vuelven a funcionar si se recrea el campo + con `Size` explícito (probado con `Size=9`, funciona con valores de + hasta 6 dígitos como `235959`). Esto es justo el caso de + `U_LastSentTM` (`HHMMSS`, hasta 6 dígitos) y `U_ReturnCode` (puede + llevar un código de error de varios dígitos). + + **Fix**: `toUserFieldPayload` (`src/provisioning/payloads.ts`) ahora + manda `Size`/`EditSize` también para `Numeric` (antes solo para + `Alpha`). Los campos numéricos en `table-definitions.ts` pasaron a + declarar `size` explícito: `Retry`/`FieldsInKe` en 6, `ReturnCode`/ + `LastSentTM` en 9. Como las tablas ya existían con los campos viejos + (limitados), hubo que además borrar y recrear esos 5 campos puntuales + en el ambiente real (`UserFieldsMD` no permite ampliar un `Size` + existente con un simple `PATCH`, o al menos no se probó esa vía — + se optó por recrear, que es lo que ya sabíamos que funciona). + Verificado con el código real (`markDelivered()`): `U_LastSentTM` + ahora persiste un valor de 6 dígitos correctamente. + +## Plan de prueba contra un SAP Business One sobre HANA real + +El usuario armó este ambiente (`192.168.1.175`, company `SBO_PRUEBA_TAGUM_22`). +Checklist para seguir avanzando (cada paso valida algo que hasta ahora era +solo un supuesto de diseño): + +1. ✅ **Login de Service Layer**: `.env` (`SL_*`) conecta contra la company + DB de esa instalación. +2. ✅ **Provisioning**: `npm run provision` corrido contra ese ambiente + real — encontró y corrigió 2 bugs reales (ver "Hallazgos confirmados + contra Service Layer real" arriba). Las 4 tablas quedaron creadas con + todos sus campos. Falta todavía confirmar que un `Code` de 36 + caracteres (UUID) entra sin truncarse en una fila real — el + provisioning solo crea metadata, no probó insertar datos. +3. ✅ **Instalar los dos SP** (`sp-webhook-capture-event-hana.sql`, después + `recreate_sbo_sp_posttransactionnotice_hana.sql`) — instalados y + compilan sin errores en esa base. De paso resolvió el punto de "comparar + contra el stub real" que estaba planificado como paso previo: al + instalarlos directamente no hubo errores de sintaxis, así que la + traducción a SQLScript quedó confirmada válida (incluido el `EXIT + HANDLER`). +4. ✅ **Transacción real**: se probó con un `PATCH` a un Item — la fila + apareció en `@OEVN` con `U_ObjectType='31'`, `U_TransType='U'`, + `U_FieldNames`/`U_FieldValue` correctos (dos claves, tab-delimited). De + paso se encontró y corrigió el hallazgo 3 (`/U_OEVN`, no `/OEVN`, para + leer datos vía Service Layer). +5. ✅ **Suscripción de prueba**: en vez de `sap-webhook-api`, se armó + [`sap-test-legacy-webhook`](../sap-test-legacy-webhook) — un receptor + mínimo aparte, mismo comportamiento núcleo, para no depender de una + instancia real. `ObjectType='31'` se descartó (no se confirmó su + `BizObject`); se probó en cambio con `ObjectType='4'` (`Items`, + confirmado real) — `subscription:add` + `filter:add Items U 4`. +6. ✅ **Dispatcher**: `npm run run-once` procesó el evento real de `Items`, + `sap-test-legacy-webhook` lo recibió y guardó + (`data/events/.json`), y `@EVN1`/`@OEVN` quedaron en `Status='D'` + con `U_LastSentDT`/`U_LastSentTM` completos (tras corregir el + hallazgo 7). + Repetido además contra `sap-test-legacy-webhook` en modo **HTTPS con + certificado autofirmado** (`npm run start:https`) — entrega exitosa, + confirma que HTTPS/certificado autofirmado nunca fue un problema del + dispatcher. +7. ⬜ **Camino de falla**: apuntar la suscripción a una URL inalcanzable, + confirmar que `U_Retry` sube en cada tick y que al superar + `RETRY_LIMIT` la suscripción pasa a `State='X'` sola. Todavía no + probado. +8. ✅ **Descarte silencioso**: observado real durante las pruebas — + eventos con `ObjectType` sin mapear (`'1470000062'`, y `'4'` en el + momento en que todavía no estaba en el mapeo) se descartaron + (`DELETE`) sin errores, tal como se diseñó. diff --git a/json/subscriptions/evc1-items-updated.json b/json/subscriptions/evc1-items-updated.json new file mode 100644 index 0000000..ec2f8dd --- /dev/null +++ b/json/subscriptions/evc1-items-updated.json @@ -0,0 +1,9 @@ +{ + "_comment": "POST /U_EVC1 — filtro para capturar Items (ObjectType=4) actualizados, para la suscripción TEST_LOCAL. Equivalente a: npx tsx scripts/manage-subscriptions.ts filter:add TEST_LOCAL Items U 4. El 'Code' es un UUID cualquiera (debe ser único, ver docs/DISENO.md decisión 5) — reemplazarlo si se postea manualmente.", + "Code": "", + "Name": "", + "U_WebhookID": "TEST_LOCAL", + "U_BizObject": "Items", + "U_TransType": "U", + "U_ObjectType": "4" +} diff --git a/json/subscriptions/oevc-test-local.json b/json/subscriptions/oevc-test-local.json new file mode 100644 index 0000000..a80d320 --- /dev/null +++ b/json/subscriptions/oevc-test-local.json @@ -0,0 +1,10 @@ +{ + "_comment": "POST /U_OEVC — suscripción de prueba, apunta al listener HTTP local levantado para esta prueba (scripts/_tmp-fake-webhook-receiver.ts). Equivalente a: npx tsx scripts/manage-subscriptions.ts subscription:add TEST_LOCAL http://127.0.0.1:4100/webhooks/sap", + "Code": "TEST_LOCAL", + "Name": "TEST_LOCAL", + "U_WebhookURL": "http://127.0.0.1:4100/webhooks/sap", + "U_AuthType": "N", + "U_AuthCred": null, + "U_State": "A", + "U_WorkMode": "S" +} diff --git a/json/user-tables/EVC1.json b/json/user-tables/EVC1.json new file mode 100644 index 0000000..8102a0b --- /dev/null +++ b/json/user-tables/EVC1.json @@ -0,0 +1,41 @@ +{ + "table": { + "TableName": "EVC1", + "TableDescription": "Webhook Subscription Filter", + "TableType": "bott_NoObject" + }, + "fields": [ + { + "TableName": "@EVC1", + "Name": "WebhookID", + "Description": "WebhookID", + "Type": "db_Alpha", + "Size": 40, + "EditSize": 40 + }, + { + "TableName": "@EVC1", + "Name": "BizObject", + "Description": "BizObject", + "Type": "db_Alpha", + "Size": 64, + "EditSize": 64 + }, + { + "TableName": "@EVC1", + "Name": "TransType", + "Description": "TransType", + "Type": "db_Alpha", + "Size": 1, + "EditSize": 1 + }, + { + "TableName": "@EVC1", + "Name": "ObjectType", + "Description": "ObjectType", + "Type": "db_Alpha", + "Size": 30, + "EditSize": 30 + } + ] +} diff --git a/json/user-tables/EVN1.json b/json/user-tables/EVN1.json new file mode 100644 index 0000000..a8e5696 --- /dev/null +++ b/json/user-tables/EVN1.json @@ -0,0 +1,63 @@ +{ + "table": { + "TableName": "EVN1", + "TableDescription": "Webhook Event Delivery", + "TableType": "bott_NoObject" + }, + "fields": [ + { + "TableName": "@EVN1", + "Name": "EventID", + "Description": "EventID", + "Type": "db_Alpha", + "Size": 40, + "EditSize": 40 + }, + { + "TableName": "@EVN1", + "Name": "WebhookID", + "Description": "WebhookID", + "Type": "db_Alpha", + "Size": 40, + "EditSize": 40 + }, + { + "TableName": "@EVN1", + "Name": "Status", + "Description": "Status", + "Type": "db_Alpha", + "Size": 1, + "EditSize": 1 + }, + { + "TableName": "@EVN1", + "Name": "Retry", + "Description": "Retry", + "Type": "db_Numeric", + "Size": 6, + "EditSize": 6 + }, + { + "TableName": "@EVN1", + "Name": "ReturnCode", + "Description": "ReturnCode", + "Type": "db_Numeric", + "Size": 9, + "EditSize": 9 + }, + { + "TableName": "@EVN1", + "Name": "LastSentDT", + "Description": "LastSentDT", + "Type": "db_Date" + }, + { + "TableName": "@EVN1", + "Name": "LastSentTM", + "Description": "LastSentTM", + "Type": "db_Numeric", + "Size": 9, + "EditSize": 9 + } + ] +} diff --git a/json/user-tables/OEVC.json b/json/user-tables/OEVC.json new file mode 100644 index 0000000..c741876 --- /dev/null +++ b/json/user-tables/OEVC.json @@ -0,0 +1,87 @@ +{ + "table": { + "TableName": "OEVC", + "TableDescription": "Webhook Subscription", + "TableType": "bott_NoObject" + }, + "fields": [ + { + "TableName": "@OEVC", + "Name": "WebhookURL", + "Description": "WebhookURL", + "Type": "db_Alpha", + "Size": 254, + "EditSize": 254 + }, + { + "TableName": "@OEVC", + "Name": "AuthType", + "Description": "AuthType", + "Type": "db_Alpha", + "Size": 1, + "EditSize": 1 + }, + { + "TableName": "@OEVC", + "Name": "AuthCred", + "Description": "AuthCred", + "Type": "db_Alpha", + "Size": 254, + "EditSize": 254 + }, + { + "TableName": "@OEVC", + "Name": "Handshake", + "Description": "Handshake", + "Type": "db_Alpha", + "Size": 1, + "EditSize": 1 + }, + { + "TableName": "@OEVC", + "Name": "VerifyCert", + "Description": "VerifyCert", + "Type": "db_Alpha", + "Size": 1, + "EditSize": 1 + }, + { + "TableName": "@OEVC", + "Name": "State", + "Description": "State", + "Type": "db_Alpha", + "Size": 1, + "EditSize": 1 + }, + { + "TableName": "@OEVC", + "Name": "WorkMode", + "Description": "WorkMode", + "Type": "db_Alpha", + "Size": 1, + "EditSize": 1 + }, + { + "TableName": "@OEVC", + "Name": "LastSentDT", + "Description": "LastSentDT", + "Type": "db_Date" + }, + { + "TableName": "@OEVC", + "Name": "LastSentTM", + "Description": "LastSentTM", + "Type": "db_Numeric", + "Size": 9, + "EditSize": 9 + }, + { + "TableName": "@OEVC", + "Name": "LastErrMsg", + "Description": "LastErrMsg", + "Type": "db_Alpha", + "Size": 254, + "EditSize": 254 + } + ] +} diff --git a/json/user-tables/OEVN.json b/json/user-tables/OEVN.json new file mode 100644 index 0000000..518a3c0 --- /dev/null +++ b/json/user-tables/OEVN.json @@ -0,0 +1,81 @@ +{ + "table": { + "TableName": "OEVN", + "TableDescription": "Webhook Event", + "TableType": "bott_NoObject" + }, + "fields": [ + { + "TableName": "@OEVN", + "Name": "SourceDB", + "Description": "SourceDB", + "Type": "db_Alpha", + "Size": 100, + "EditSize": 100 + }, + { + "TableName": "@OEVN", + "Name": "Status", + "Description": "Status", + "Type": "db_Alpha", + "Size": 1, + "EditSize": 1 + }, + { + "TableName": "@OEVN", + "Name": "ObjectType", + "Description": "ObjectType", + "Type": "db_Alpha", + "Size": 30, + "EditSize": 30 + }, + { + "TableName": "@OEVN", + "Name": "TransType", + "Description": "TransType", + "Type": "db_Alpha", + "Size": 1, + "EditSize": 1 + }, + { + "TableName": "@OEVN", + "Name": "FieldsInKe", + "Description": "FieldsInKe", + "Type": "db_Numeric", + "Size": 6, + "EditSize": 6 + }, + { + "TableName": "@OEVN", + "Name": "FieldNames", + "Description": "FieldNames", + "Type": "db_Alpha", + "Size": 254, + "EditSize": 254 + }, + { + "TableName": "@OEVN", + "Name": "FieldValue", + "Description": "FieldValue", + "Type": "db_Alpha", + "Size": 254, + "EditSize": 254 + }, + { + "TableName": "@OEVN", + "Name": "ReplayStat", + "Description": "ReplayStat", + "Type": "db_Alpha", + "Size": 1, + "EditSize": 1 + }, + { + "TableName": "@OEVN", + "Name": "UserID", + "Description": "UserID", + "Type": "db_Alpha", + "Size": 25, + "EditSize": 25 + } + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..dbe2242 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1293 @@ +{ + "name": "sap-legacy-webhook-emulator", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sap-legacy-webhook-emulator", + "version": "1.0.0", + "license": "UNLICENSED", + "dependencies": { + "axios": "^1.19.0", + "dotenv": "^17.4.2", + "node-cron": "^4.6.0" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "@types/node-cron": "^3.0.11", + "tsx": "^4.23.12", + "typescript": "^7.0.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "26.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.0.tgz", + "integrity": "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-cron": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.6.0.tgz", + "integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==", + "license": "ISC", + "engines": { + "node": ">=20" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9345306 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "sap-legacy-webhook-emulator", + "version": "1.0.0", + "description": "Emula el Webhook Messenger nativo de SAP B1 (FP2605+) para clientes en versiones anteriores", + "type": "module", + "main": "src/index.ts", + "scripts": { + "provision": "tsx src/provisioning/run-provision.ts", + "start": "tsx src/index.ts", + "dev": "tsx watch src/index.ts", + "run-once": "tsx src/index.ts --once" + }, + "keywords": [], + "author": "", + "license": "UNLICENSED", + "private": true, + "dependencies": { + "axios": "^1.19.0", + "dotenv": "^17.4.2", + "node-cron": "^4.6.0" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "@types/node-cron": "^3.0.11", + "tsx": "^4.23.12", + "typescript": "^7.0.2" + } +} diff --git a/scripts/_tmp-fake-webhook-receiver.ts b/scripts/_tmp-fake-webhook-receiver.ts new file mode 100644 index 0000000..78ff8c4 --- /dev/null +++ b/scripts/_tmp-fake-webhook-receiver.ts @@ -0,0 +1,28 @@ +// Listener HTTP mínimo, solo para esta prueba: simula sap-webhook-api pero +// en vez de guardar en Postgres, escribe cada POST recibido como JSON en +// tmp-test-events/ para poder inspeccionarlo. Se borra todo (este archivo +// y la carpeta) al terminar la prueba. +import { createServer } from 'node:http'; +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const outDir = fileURLToPath(new URL('../tmp-test-events/', import.meta.url)); +const port = 4100; + +const server = createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + console.log(`[fake-webhook] ${req.method} ${req.url}`); + if (req.method === 'POST') { + const fileName = `${Date.now()}.json`; + writeFileSync(`${outDir}${fileName}`, body); + console.log(`[fake-webhook] guardado: tmp-test-events/${fileName}`); + console.log(body); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ received: true })); + }); +}); + +server.listen(port, () => console.log(`[fake-webhook] escuchando en http://127.0.0.1:${port}`)); diff --git a/scripts/export-table-json.ts b/scripts/export-table-json.ts new file mode 100644 index 0000000..a34bdfa --- /dev/null +++ b/scripts/export-table-json.ts @@ -0,0 +1,29 @@ +// Exporta el body real de creación (Service Layer: UserTablesMD/UserFieldsMD) +// de cada una de las 4 UserTables a un archivo JSON en json/user-tables/ — +// útil para revisar el payload exacto, o para postearlo a mano (Postman, +// curl, tools/sl.http) sin correr `npm run provision`. +// +// Se genera a partir de src/provisioning/table-definitions.ts (única fuente +// de verdad) — si el esquema cambia ahí, correr de nuevo este script para +// regenerar los JSON. +// +// Uso: npx tsx scripts/export-table-json.ts + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { TABLE_DEFINITIONS } from '../src/provisioning/table-definitions.ts'; +import { toUserFieldPayload, toUserTablePayload } from '../src/provisioning/payloads.ts'; + +const outDir = fileURLToPath(new URL('../json/user-tables/', import.meta.url)); +mkdirSync(outDir, { recursive: true }); + +for (const definition of TABLE_DEFINITIONS) { + const payload = { + table: toUserTablePayload(definition), + fields: definition.fields.map((field) => toUserFieldPayload(definition.tableName, field)), + }; + + const filePath = `${outDir}${definition.tableName}.json`; + writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`); + console.log(`Exportado: ${filePath}`); +} diff --git a/scripts/manage-subscriptions.ts b/scripts/manage-subscriptions.ts new file mode 100644 index 0000000..b80aa2c --- /dev/null +++ b/scripts/manage-subscriptions.ts @@ -0,0 +1,151 @@ +// CLI de administración de suscripciones (@OEVC/@EVC1) contra el Service +// Layer configurado en .env. Requiere que las 4 UserTables ya existan +// (`npm run provision`). +// +// Uso: +// npx tsx scripts/manage-subscriptions.ts subscription:add [--hmac-key ] +// npx tsx scripts/manage-subscriptions.ts subscription:list +// npx tsx scripts/manage-subscriptions.ts subscription:update-url +// npx tsx scripts/manage-subscriptions.ts subscription:pause +// npx tsx scripts/manage-subscriptions.ts subscription:resume +// +// npx tsx scripts/manage-subscriptions.ts filter:add +// npx tsx scripts/manage-subscriptions.ts filter:remove +// npx tsx scripts/manage-subscriptions.ts filter:list + +import { config } from '../src/config.ts'; +import { ServiceLayerClient } from '../src/service-layer-client.ts'; +import { generateCode } from '../src/lib/codes.ts'; +import { userTableEntity } from '../src/lib/service-layer-paths.ts'; + +const client = new ServiceLayerClient(config.serviceLayer); +const OEVC = userTableEntity('OEVC'); +const EVC1 = userTableEntity('EVC1'); + +function fail(message: string): never { + console.error(`Error: ${message}`); + process.exit(1); +} + +function flag(args: string[], name: string): string | undefined { + const index = args.indexOf(`--${name}`); + return index === -1 ? undefined : args[index + 1]; +} + +async function main() { + const [command, ...args] = process.argv.slice(2); + + switch (command) { + case 'subscription:add': { + const [webhookId, webhookUrl] = args; + if (!webhookId || !webhookUrl) fail('subscription:add [--hmac-key ]'); + + const hmacKey = flag(args, 'hmac-key'); + await client.post(`/${OEVC}`, { + Code: webhookId, + Name: webhookId, + U_WebhookURL: webhookUrl, + U_AuthType: hmacKey ? 'H' : 'N', + U_AuthCred: hmacKey ? JSON.stringify({ Key: hmacKey, Algorithm: 'hmacsha256' }) : null, + U_State: 'A', + U_WorkMode: 'S', + }); + console.log(`Suscripción ${webhookId} creada (${webhookUrl}).`); + break; + } + + case 'subscription:list': { + const rows = await client.getAll(`/${OEVC}`); + console.table( + rows.map((row) => ({ + webhookId: row.Code, + url: row.U_WebhookURL, + authType: row.U_AuthType, + state: row.U_State, + })), + ); + break; + } + + case 'subscription:update-url': { + const [webhookId, webhookUrl] = args; + if (!webhookId || !webhookUrl) fail('subscription:update-url '); + await client.patch(`/${OEVC}('${webhookId}')`, { U_WebhookURL: webhookUrl }); + console.log(`Suscripción ${webhookId} actualizada -> ${webhookUrl}.`); + break; + } + + case 'subscription:pause': { + const [webhookId] = args; + if (!webhookId) fail('subscription:pause '); + await client.patch(`/${OEVC}('${webhookId}')`, { U_State: 'I' }); + console.log(`Suscripción ${webhookId} pausada (State='I').`); + break; + } + + case 'subscription:resume': { + const [webhookId] = args; + if (!webhookId) fail('subscription:resume '); + await client.patch(`/${OEVC}('${webhookId}')`, { U_State: 'A' }); + console.log(`Suscripción ${webhookId} reactivada (State='A').`); + break; + } + + case 'filter:add': { + const [webhookId, bizObject, transType, objectType] = args; + if (!webhookId || !bizObject || !transType || !objectType) { + fail('filter:add '); + } + await client.post(`/${EVC1}`, { + Code: generateCode(), + Name: `${webhookId}/${bizObject}/${transType}`, + U_WebhookID: webhookId, + U_BizObject: bizObject, + U_TransType: transType, + U_ObjectType: objectType, + }); + console.log(`Filtro agregado: ${webhookId} -> ${bizObject}/${transType} (ObjectType ${objectType}).`); + break; + } + + case 'filter:remove': { + const [webhookId, bizObject, transType] = args; + if (!webhookId || !bizObject || !transType) { + fail('filter:remove '); + } + const filter = encodeURIComponent( + `U_WebhookID eq '${webhookId}' and U_BizObject eq '${bizObject}' and U_TransType eq '${transType}'`, + ); + const res = await client.get(`/${EVC1}?$filter=${filter}`); + const rows = (res.data?.value as { Code: string }[]) ?? []; + if (rows.length === 0) fail(`no existe el filtro ${webhookId} -> ${bizObject}/${transType}`); + await client.delete(`/${EVC1}('${rows[0].Code}')`); + console.log(`Filtro eliminado: ${webhookId} -> ${bizObject}/${transType}.`); + break; + } + + case 'filter:list': { + const rows = await client.getAll(`/${EVC1}`); + console.table( + rows.map((row) => ({ + webhookId: row.U_WebhookID, + bizObject: row.U_BizObject, + transType: row.U_TransType, + objectType: row.U_ObjectType, + })), + ); + break; + } + + default: + fail( + `comando desconocido: ${command ?? '(ninguno)'}\nComandos disponibles: subscription:add, subscription:list, ` + + 'subscription:update-url, subscription:pause, subscription:resume, filter:add, filter:remove, filter:list', + ); + } +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/sql/transaction-notification/alter_sbo_sp_posttransactionnotice.sql b/sql/transaction-notification/alter_sbo_sp_posttransactionnotice.sql new file mode 100644 index 0000000..d52c0bc --- /dev/null +++ b/sql/transaction-notification/alter_sbo_sp_posttransactionnotice.sql @@ -0,0 +1,33 @@ +-- SBO_SP_PostTransactionNotice — SQL Server / T-SQL +-- +-- Corre DESPUÉS del commit de la transacción real de SAP (a diferencia de +-- SBO_SP_TransactionNotification, que es síncrono y puede hacer rollback). +-- Es un hook compartido de SAP (SAP lo crea vacío por cada compañía nueva, +-- y ahí puede convivir código de otros add-ons) — por eso el cuerpo se deja +-- reducido a un solo llamado a SP_Webhook_CaptureEvent +-- (ver sp-webhook-capture-event-mssql.sql), donde vive toda la lógica. +-- +-- Instalar, en este orden: +-- 1) sp-webhook-capture-event-mssql.sql +-- 2) este archivo (reemplaza el cuerpo del SBO_SP_PostTransactionNotice +-- existente en la company DB) + +ALTER PROCEDURE [dbo].[SBO_SP_PostTransactionNotice] + @object_type NVARCHAR(20), + @transaction_type NCHAR(1), + @num_of_cols_in_key INT, + @list_of_key_cols_tab_del NVARCHAR(255), + @list_of_cols_val_tab_del NVARCHAR(255) +AS +BEGIN + DECLARE @error INT + DECLARE @error_message NVARCHAR(200) + + EXEC [dbo].[SP_Webhook_CaptureEvent] + @object_type, @transaction_type, @num_of_cols_in_key, + @list_of_key_cols_tab_del, @list_of_cols_val_tab_del, + @error OUTPUT, @error_message OUTPUT + + SELECT @error, @error_message +END +GO diff --git a/sql/transaction-notification/recreate_sbo_sp_posttransactionnotice_hana.sql b/sql/transaction-notification/recreate_sbo_sp_posttransactionnotice_hana.sql new file mode 100644 index 0000000..0d526af --- /dev/null +++ b/sql/transaction-notification/recreate_sbo_sp_posttransactionnotice_hana.sql @@ -0,0 +1,40 @@ +-- SBO_SP_PostTransactionNotice — SAP HANA / SQLScript +-- +-- Corre DESPUÉS del commit de la transacción real de SAP. Es un hook +-- compartido de SAP (SAP lo crea vacío por cada compañía nueva, y ahí +-- puede convivir código de otros add-ons) — por eso el cuerpo se deja +-- reducido a un solo CALL a SP_Webhook_CaptureEvent +-- (ver sp-webhook-capture-event-hana.sql), donde vive toda la lógica. +-- +-- A diferencia de SQL Server (que soporta ALTER PROCEDURE), en HANA no se +-- puede modificar el cuerpo de un procedimiento existente — hay que +-- reemplazarlo con DROP + CREATE. El DROP asume que el procedimiento ya +-- existe (SAP lo crea automáticamente al crear la compañía). +-- +-- ⚠️ NO VERIFICADO CONTRA UN HANA REAL — ver el aviso completo en +-- sp-webhook-capture-event-hana.sql. +-- +-- Instalar, en este orden: +-- 1) sp-webhook-capture-event-hana.sql +-- 2) este archivo + +DROP PROCEDURE "SBO_SP_PostTransactionNotice"; + +CREATE PROCEDURE "SBO_SP_PostTransactionNotice" ( + IN object_type NVARCHAR(20), + IN transaction_type NVARCHAR(1), + IN num_of_cols_in_key INTEGER, + IN list_of_key_cols_tab_del NVARCHAR(255), + IN list_of_cols_val_tab_del NVARCHAR(255), + OUT error INTEGER, + OUT error_message NVARCHAR(200) +) +LANGUAGE SQLSCRIPT +AS +BEGIN + CALL "SP_Webhook_CaptureEvent" ( + :object_type, :transaction_type, :num_of_cols_in_key, + :list_of_key_cols_tab_del, :list_of_cols_val_tab_del, + error, error_message + ); +END; diff --git a/sql/transaction-notification/sp-webhook-capture-event-hana.sql b/sql/transaction-notification/sp-webhook-capture-event-hana.sql new file mode 100644 index 0000000..9807ac8 --- /dev/null +++ b/sql/transaction-notification/sp-webhook-capture-event-hana.sql @@ -0,0 +1,103 @@ +-- SP_Webhook_CaptureEvent — SAP HANA / SQLScript +-- +-- Equivalente HANA de sp-webhook-capture-event-mssql.sql: toda la lógica +-- de captura vive acá, no en el cuerpo de SBO_SP_PostTransactionNotice +-- (hook compartido de SAP, ver recreate_sbo_sp_posttransactionnotice_hana.sql). +-- +-- Requiere que la UserTable "@OEVN" ya exista (creada por +-- `npm run provision`, ver src/provisioning/). +-- +-- ✅ Verificado contra un HANA real (SAP Business One), instalado y +-- funcionando — incluida la sintaxis del EXIT HANDLER y el filtro de eco +-- (INSERT...SELECT...WHERE, más abajo): se confirmó que un DELETE vía +-- Service Layer sobre una fila de @OEVN ya no genera ningún eco nuevo. +-- ⚠️ El filtro por suscripción activa (EXISTS contra @EVC1/@OEVC, más +-- abajo) es nuevo y todavía NO se probó contra ese ambiente. +-- +-- U_UserID: no se completa. CURRENT_USER da el usuario de la conexión a +-- HANA (una cuenta técnica), no el usuario real de SAP que hizo la +-- transacción — confirmado contra este mismo ambiente. Ninguno de los 5 +-- parámetros que recibe el hook trae ese dato, así que por ahora el campo +-- queda sin poblar en las dos variantes (ver también mssql.sql). +-- +-- Name: en una UserTable de SAP, "Name" también debe ser único (no solo +-- "Code") — confirmado contra Service Layer real (ODBC -2035 al repetir +-- un Name, con Code distinto). Por eso Name usa el mismo UUID que Code: +-- cualquier otro valor (como el propio ObjectType) se repite entre +-- eventos del mismo tipo y hace fallar el INSERT en el segundo evento +-- (en silencio, por el EXIT HANDLER). +-- +-- Eco de nuestras propias escrituras: cualquier POST/PATCH/DELETE que +-- hace el dispatcher sobre @OEVC/@EVC1/@OEVN/@EVN1 vía Service Layer +-- dispara este mismo hook de nuevo, con un ObjectType especial de SAP +-- para "operación genérica sobre una UserTable simple": un código +-- negativo + tab + nombre físico de la tabla (confirmado real: +-- "-3\t@OEVN" para un DELETE). Si insertáramos esa fila iba a generar +-- otro eco al borrarla, indefinidamente — por eso el INSERT usa +-- SELECT...FROM DUMMY WHERE en vez de VALUES, para poder filtrar el +-- ObjectType sin agregar una rama procedural nueva (ver docs/DISENO.md, +-- hallazgo 5). +-- +-- Filtro por suscripción activa: además de lo anterior, solo se inserta +-- si existe al menos una fila en @EVC1 (con su @OEVC en State='A') para +-- este ObjectType+TransType exacto. No es "lógica de negocio propia" del +-- SP — consulta la misma tabla de configuración que ya es la fuente de +-- verdad para el dispatcher, no una copia/lista fija a mantener +-- sincronizada. Evita ensuciar @OEVN con todo lo que SAP genera y a +-- nadie le interesa (en el ambiente de prueba, cientos de eventos por +-- minuto sin ninguna suscripción real). + +DROP PROCEDURE "SP_Webhook_CaptureEvent"; + +CREATE PROCEDURE "SP_Webhook_CaptureEvent" ( + IN object_type NVARCHAR(20), + IN transaction_type NVARCHAR(1), + IN num_of_cols_in_key INTEGER, + IN list_of_key_cols_tab_del NVARCHAR(255), + IN list_of_cols_val_tab_del NVARCHAR(255), + OUT error INTEGER, + OUT error_message NVARCHAR(200) +) +LANGUAGE SQLSCRIPT +AS +BEGIN + DECLARE v_code NVARCHAR(36); + + DECLARE EXIT HANDLER FOR SQLEXCEPTION + BEGIN + error := ::SQL_ERROR_CODE; + error_message := SUBSTR(::SQL_ERROR_MESSAGE, 1, 200); + END; + + error := 0; + error_message := N'Ok'; + + -- Sin condiciones de negocio: inserta siempre, salvo que sea un eco + -- de una escritura nuestra sobre estas mismas 4 tablas (arriba). + SELECT SYSUUID INTO v_code FROM DUMMY; + + INSERT INTO "@OEVN" ( + "Code", "Name", + "U_SourceDB", "U_Status", "U_ObjectType", "U_TransType", + "U_FieldsInKe", "U_FieldNames", "U_FieldValue", "U_ReplayStat" + ) + SELECT + :v_code, :v_code, + CURRENT_SCHEMA, 'N', :object_type, :transaction_type, + :num_of_cols_in_key, :list_of_key_cols_tab_del, :list_of_cols_val_tab_del, 'N' + FROM DUMMY + WHERE NOT ( + :object_type LIKE '-%' || CHAR(9) || '@OEVC' + OR :object_type LIKE '-%' || CHAR(9) || '@EVC1' + OR :object_type LIKE '-%' || CHAR(9) || '@OEVN' + OR :object_type LIKE '-%' || CHAR(9) || '@EVN1' + ) + AND EXISTS ( + SELECT 1 + FROM "@EVC1" f + INNER JOIN "@OEVC" s ON s."Code" = f."U_WebhookID" + WHERE f."U_ObjectType" = :object_type + AND f."U_TransType" = :transaction_type + AND s."U_State" = 'A' + ); +END; diff --git a/sql/transaction-notification/sp-webhook-capture-event-mssql.sql b/sql/transaction-notification/sp-webhook-capture-event-mssql.sql new file mode 100644 index 0000000..ff4cf5a --- /dev/null +++ b/sql/transaction-notification/sp-webhook-capture-event-mssql.sql @@ -0,0 +1,98 @@ +-- SP_Webhook_CaptureEvent — SQL Server / T-SQL +-- +-- Toda la lógica de captura del emulador vive acá, en un procedimiento +-- propio y con nombre descriptivo — no directamente en el cuerpo de +-- SBO_SP_PostTransactionNotice (ver mssql.sql), que es un hook compartido +-- de SAP donde puede convivir código de otros add-ons. Así el nuestro +-- queda identificable y fácil de mantener o remover por separado. +-- +-- Requiere que la UserTable "@OEVN" ya exista (creada por +-- `npm run provision`, ver src/provisioning/). +-- +-- U_UserID: no se completa. SUSER_SNAME() da el login de la conexión a SQL +-- Server (una cuenta técnica), no el usuario real de SAP que hizo la +-- transacción — confirmado equivalente en HANA (CURRENT_USER, ver +-- sp-webhook-capture-event-hana.sql). Ninguno de los 5 parámetros que +-- recibe el hook trae ese dato, así que por ahora el campo queda sin +-- poblar en las dos variantes. +-- +-- Name: en una UserTable de SAP, "Name" también debe ser único (no solo +-- "Code") — confirmado contra Service Layer real (ODBC -2035 al repetir +-- un Name, con Code distinto). Por eso Name usa el mismo UUID que Code: +-- cualquier otro valor (como el propio ObjectType) se repite entre +-- eventos del mismo tipo y hace fallar el INSERT en el segundo evento +-- (en silencio, por el TRY/CATCH). +-- +-- Eco de nuestras propias escrituras: cualquier POST/PATCH/DELETE que +-- hace el dispatcher sobre @OEVC/@EVC1/@OEVN/@EVN1 vía Service Layer +-- dispara este mismo hook de nuevo, con un ObjectType especial de SAP +-- para "operación genérica sobre una UserTable simple": un código +-- negativo + tab + nombre físico de la tabla (confirmado real: +-- "-3\t@OEVN" para un DELETE). Si insertáramos esa fila iba a generar +-- otro eco al borrarla, indefinidamente — por eso NO se inserta nada +-- cuando el ObjectType matchea ese patrón para una de nuestras 4 tablas +-- (ver docs/DISENO.md, hallazgo 5). +-- +-- Filtro por suscripción activa: además de lo anterior, solo se inserta +-- si existe al menos una fila en @EVC1 (con su @OEVC en State='A') para +-- este ObjectType+TransType exacto. No es "lógica de negocio propia" del +-- SP — consulta la misma tabla de configuración que ya es la fuente de +-- verdad para el dispatcher, no una copia/lista fija a mantener +-- sincronizada. Evita ensuciar @OEVN con todo lo que SAP genera y a +-- nadie le interesa (en el ambiente de prueba, cientos de eventos por +-- minuto sin ninguna suscripción real). + +CREATE OR ALTER PROCEDURE [dbo].[SP_Webhook_CaptureEvent] + @object_type NVARCHAR(20), + @transaction_type NCHAR(1), + @num_of_cols_in_key INT, + @list_of_key_cols_tab_del NVARCHAR(255), + @list_of_cols_val_tab_del NVARCHAR(255), + @error INT OUTPUT, + @error_message NVARCHAR(200) OUTPUT +AS +BEGIN + SET @error = 0 + SET @error_message = N'Ok' + + DECLARE @code NVARCHAR(36) = CONVERT(NVARCHAR(36), NEWID()) + + DECLARE @isOwnTableEcho BIT = CASE + WHEN @object_type LIKE '-%' + CHAR(9) + '@OEVC' THEN 1 + WHEN @object_type LIKE '-%' + CHAR(9) + '@EVC1' THEN 1 + WHEN @object_type LIKE '-%' + CHAR(9) + '@OEVN' THEN 1 + WHEN @object_type LIKE '-%' + CHAR(9) + '@EVN1' THEN 1 + ELSE 0 + END + + DECLARE @hasActiveSubscription BIT = CASE WHEN EXISTS ( + SELECT 1 + FROM [dbo].[@EVC1] f + INNER JOIN [dbo].[@OEVC] s ON s.Code = f.U_WebhookID + WHERE f.U_ObjectType = @object_type + AND f.U_TransType = @transaction_type + AND s.U_State = 'A' + ) THEN 1 ELSE 0 END + + -- Solo inserta si no es un eco de nuestras propias tablas (arriba) y + -- si hay una suscripción activa interesada en este ObjectType+TransType. + IF @isOwnTableEcho = 0 AND @hasActiveSubscription = 1 + BEGIN + BEGIN TRY + INSERT INTO [dbo].[@OEVN] ( + Code, Name, + U_SourceDB, U_Status, U_ObjectType, U_TransType, + U_FieldsInKe, U_FieldNames, U_FieldValue, U_ReplayStat + ) VALUES ( + @code, @code, + DB_NAME(), 'N', @object_type, @transaction_type, + @num_of_cols_in_key, @list_of_key_cols_tab_del, @list_of_cols_val_tab_del, 'N' + ) + END TRY + BEGIN CATCH + SET @error = ERROR_NUMBER() + SET @error_message = LEFT(ERROR_MESSAGE(), 200) + END CATCH + END +END +GO diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..cc42d7a --- /dev/null +++ b/src/config.ts @@ -0,0 +1,28 @@ +import 'dotenv/config'; + +export interface ServiceLayerTarget { + baseUrl: string; + companyDb: string; + username: string; + password: string; +} + +export const config = { + serviceLayer: { + baseUrl: requireEnv('SL_BASE_URL'), + companyDb: requireEnv('SL_COMPANY_DB'), + username: requireEnv('SL_USERNAME'), + password: requireEnv('SL_PASSWORD'), + } satisfies ServiceLayerTarget, + // El destino y la autenticación de cada envío salen de @OEVC (WebhookURL/ + // AuthType/AuthCred), no de config — una suscripción puede apuntar a + // cualquier URL, no solo a una instancia fija de sap-webhook-api. + pollIntervalSeconds: Number(process.env.POLL_INTERVAL_SECONDS ?? 30), + retryLimit: Number(process.env.RETRY_LIMIT ?? 5), +}; + +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`Falta la variable de entorno ${name}`); + return value; +} diff --git a/src/dispatcher/evn1-repository.ts b/src/dispatcher/evn1-repository.ts new file mode 100644 index 0000000..ab93a21 --- /dev/null +++ b/src/dispatcher/evn1-repository.ts @@ -0,0 +1,72 @@ +import type { ServiceLayerClient } from '../service-layer-client.ts'; +import { generateCode } from '../lib/codes.ts'; +import { hhmmss } from '../lib/timestamps.ts'; +import { userTableEntity } from '../lib/service-layer-paths.ts'; + +const ENTITY = userTableEntity('EVN1'); + +export interface Evn1Row { + code: string; + eventId: string; + webhookId: string; + status: string; + retry: number; +} + +function toRow(raw: Record): Evn1Row { + return { + code: raw.Code as string, + eventId: raw.U_EventID as string, + webhookId: raw.U_WebhookID as string, + status: raw.U_Status as string, + retry: (raw.U_Retry as number) ?? 0, + }; +} + +export async function findExisting(client: ServiceLayerClient, eventId: string, webhookId: string): Promise { + const filter = encodeURIComponent(`U_EventID eq '${eventId}' and U_WebhookID eq '${webhookId}'`); + const res = await client.get(`/${ENTITY}?$filter=${filter}`); + const rows = (res.data?.value as Record[]) ?? []; + return rows.length > 0 ? toRow(rows[0]) : null; +} + +export async function create(client: ServiceLayerClient, eventId: string, webhookId: string): Promise { + await client.post(`/${ENTITY}`, { + Code: generateCode(), + Name: `${eventId}/${webhookId}`, + U_EventID: eventId, + U_WebhookID: webhookId, + U_Status: 'F', + U_Retry: 0, + }); +} + +export async function listPending(client: ServiceLayerClient): Promise { + const rows = await client.getAll(`/${ENTITY}?$filter=${encodeURIComponent("U_Status eq 'F'")}`); + return rows.map(toRow); +} + +export async function listForEvent(client: ServiceLayerClient, eventId: string): Promise { + const rows = await client.getAll(`/${ENTITY}?$filter=${encodeURIComponent(`U_EventID eq '${eventId}'`)}`); + return rows.map(toRow); +} + +export async function markDelivered(client: ServiceLayerClient, code: string, returnCode: number): Promise { + const now = new Date(); + await client.patch(`/${ENTITY}('${code}')`, { + U_Status: 'D', + U_ReturnCode: returnCode, + U_LastSentDT: now.toISOString(), + U_LastSentTM: hhmmss(now), + }); +} + +export async function markFailedAttempt(client: ServiceLayerClient, row: Evn1Row, returnCode: number): Promise { + const now = new Date(); + await client.patch(`/${ENTITY}('${row.code}')`, { + U_Retry: row.retry + 1, + U_ReturnCode: returnCode, + U_LastSentDT: now.toISOString(), + U_LastSentTM: hhmmss(now), + }); +} diff --git a/src/dispatcher/oevn-repository.ts b/src/dispatcher/oevn-repository.ts new file mode 100644 index 0000000..ab9c63f --- /dev/null +++ b/src/dispatcher/oevn-repository.ts @@ -0,0 +1,44 @@ +import type { ServiceLayerClient } from '../service-layer-client.ts'; +import { userTableEntity } from '../lib/service-layer-paths.ts'; + +const ENTITY = userTableEntity('OEVN'); + +export interface OevnRow { + code: string; + sourceDb: string; + status: string; + objectType: string; + transType: string; + fieldNames: string; + fieldValue: string; +} + +function toRow(raw: Record): OevnRow { + return { + code: raw.Code as string, + sourceDb: raw.U_SourceDB as string, + status: raw.U_Status as string, + objectType: raw.U_ObjectType as string, + transType: raw.U_TransType as string, + fieldNames: raw.U_FieldNames as string, + fieldValue: raw.U_FieldValue as string, + }; +} + +export async function listNewEvents(client: ServiceLayerClient): Promise { + const rows = await client.getAll(`/${ENTITY}?$filter=${encodeURIComponent("U_Status eq 'N'")}`); + return rows.map(toRow); +} + +export async function getEvent(client: ServiceLayerClient, code: string): Promise { + const res = await client.get(`/${ENTITY}('${code}')`); + return res.status === 200 ? toRow(res.data) : null; +} + +export async function deleteEvent(client: ServiceLayerClient, code: string): Promise { + await client.delete(`/${ENTITY}('${code}')`); +} + +export async function setEventStatus(client: ServiceLayerClient, code: string, status: string): Promise { + await client.patch(`/${ENTITY}('${code}')`, { U_Status: status }); +} diff --git a/src/dispatcher/own-table-guard.ts b/src/dispatcher/own-table-guard.ts new file mode 100644 index 0000000..9ff2360 --- /dev/null +++ b/src/dispatcher/own-table-guard.ts @@ -0,0 +1,18 @@ +// SAP emite un `object_type` especial para operaciones sobre cualquier +// UserTable simple (no registrada como UDO completo): un código negativo +// seguido de un tab y el nombre físico de la tabla (con "@"), en vez de un +// código positivo de BoObjectTypes. Confirmado contra Service Layer real: +// un DELETE sobre "@OEVN" hecho por el propio dispatcher generó +// object_type = "-3\t@OEVN" — es decir, nuestras propias escrituras +// (DELETE/PATCH/POST sobre @OEVC/@EVC1/@OEVN/@EVN1 vía Service Layer) +// producen un evento nuevo describiéndose a sí mismas. Si tratáramos ese +// evento como cualquier otro (sin match → borrar), el borrado generaría +// otro eco, y así indefinidamente. Por eso estos eventos nunca se tocan: +// ni se resuelven, ni se borran — quedan con Status='N' para siempre, +// cortando la cascada en el primer nivel. Ver docs/DISENO.md. +const OWN_TABLES = new Set(['@OEVC', '@EVC1', '@OEVN', '@EVN1']); + +export function isEchoOfOwnTable(objectType: string): boolean { + const [code, tableName] = objectType.split('\t'); + return code !== undefined && code.startsWith('-') && OWN_TABLES.has(tableName ?? ''); +} diff --git a/src/dispatcher/process-tick.ts b/src/dispatcher/process-tick.ts new file mode 100644 index 0000000..a17cfed --- /dev/null +++ b/src/dispatcher/process-tick.ts @@ -0,0 +1,144 @@ +import type { ServiceLayerClient } from '../service-layer-client.ts'; +import { config } from '../config.ts'; +import { bizObjectFor } from '../mapping/object-types.ts'; +import { buildCloudEvent } from '../lib/cloud-event.ts'; +import * as oevn from './oevn-repository.ts'; +import * as evn1 from './evn1-repository.ts'; +import * as subscriptions from '../subscriptions/subscriptions-repository.ts'; +import { sendBatch, type SendResult } from './send-webhook.ts'; +import { isEchoOfOwnTable } from './own-table-guard.ts'; +import { logger } from '../lib/logger.ts'; + +// Un tick del dispatcher: registrar eventos nuevos capturados por el SP +// (asignarles las suscripciones que les corresponden) y despachar todo lo +// que siga pendiente de entrega — nuevo o reintento. Ver docs/DISENO.md, +// sección "Dispatcher — lenguaje, arquitectura y flujo". +export async function processTick(client: ServiceLayerClient): Promise { + const newCount = await registerNewEvents(client); + const pendingCount = await sendPendingDeliveries(client); + // Siempre deja un rastro, aunque no haya nada para hacer — así un + // visualizador de logs muestra que el proceso sigue vivo entre tanda y + // tanda de actividad real. + logger.info(`[dispatcher] Tick: ${newCount} evento(s) nuevo(s), ${pendingCount} pendiente(s) de entrega.`); +} + +async function registerNewEvents(client: ServiceLayerClient): Promise { + const newEvents = await oevn.listNewEvents(client); + if (newEvents.length === 0) return 0; + + const filters = await subscriptions.listFilters(client); + + for (const event of newEvents) { + // Eco de una escritura nuestra sobre @OEVC/@EVC1/@OEVN/@EVN1 (ver + // own-table-guard.ts) — nunca se toca, ni se resuelve ni se borra, + // para no perpetuar la cascada. + if (isEchoOfOwnTable(event.objectType)) continue; + + let bizObject: string; + try { + bizObject = bizObjectFor(event.objectType); + } catch { + logger.warn(`[dispatcher] ObjectType desconocido '${event.objectType}' (evento ${event.code}) — se descarta.`); + await oevn.deleteEvent(client, event.code); + continue; + } + + const matches = filters.filter((f) => f.bizObject === bizObject && f.transType === event.transType); + if (matches.length === 0) { + logger.info(`[dispatcher] Sin suscripciones para ${bizObject}/${event.transType} (evento ${event.code}) — se descarta.`); + await oevn.deleteEvent(client, event.code); + continue; + } + + for (const match of matches) { + const existing = await evn1.findExisting(client, event.code, match.webhookId); + if (!existing) await evn1.create(client, event.code, match.webhookId); + } + await oevn.setEventStatus(client, event.code, 'P'); + } + return newEvents.length; +} + +async function sendPendingDeliveries(client: ServiceLayerClient): Promise { + const pending = await evn1.listPending(client); + if (pending.length === 0) return 0; + + const activeSubscriptions = await subscriptions.listActiveSubscriptions(client); + const byWebhookId = new Map(); + + for (const row of pending) { + if (row.retry >= config.retryLimit) { + if (activeSubscriptions.has(row.webhookId)) { + logger.warn(`[dispatcher] Suscripción ${row.webhookId} superó ${config.retryLimit} reintentos — pasa a State='X'.`); + await subscriptions.markExceptional(client, row.webhookId); + activeSubscriptions.delete(row.webhookId); + } + continue; + } + if (!activeSubscriptions.has(row.webhookId)) continue; // pausada / exceptional: no reintentar + + const group = byWebhookId.get(row.webhookId) ?? []; + group.push(row); + byWebhookId.set(row.webhookId, group); + } + + for (const [webhookId, rows] of byWebhookId) { + await sendGroup(client, activeSubscriptions.get(webhookId)!, rows); + } + + await recomputeEventStatuses(client, [...new Set(pending.map((r) => r.eventId))]); + return pending.length; +} + +async function sendGroup( + client: ServiceLayerClient, + subscription: subscriptions.Subscription, + rows: evn1.Evn1Row[], +): Promise { + const events = []; + const deliverableRows: evn1.Evn1Row[] = []; + for (const row of rows) { + const event = await oevn.getEvent(client, row.eventId); + if (!event) continue; // evento borrado/inconsistente, se ignora + events.push(buildCloudEvent(event)); + deliverableRows.push(row); + } + if (events.length === 0) return; + + let result: SendResult; + try { + result = await sendBatch(subscription, events); + } catch (err) { + logger.warn(`[dispatcher] Error de red mandando a ${subscription.url}: ${err instanceof Error ? err.message : String(err)}`); + result = { ok: false, status: 0, body: '', finalUrl: subscription.url }; + } + + // "Entregado" aquí significa solo "el receptor contestó 2xx". Se registran + // status y respuesta porque un destino asíncrono puede aceptar con 202 y + // devolver un id de ejecución: sin ese dato no hay cómo distinguir una + // entrega procesada de una encolada que después se descartó. + const detail = `HTTP ${result.status}${result.body ? ` ${result.body}` : ''}`; + const redirect = result.finalUrl !== subscription.url ? ` [redirigido a ${result.finalUrl}]` : ''; + + if (result.ok) { + logger.info( + `[dispatcher] Entregados ${events.length} evento(s) a ${subscription.webhookId} (${subscription.url}): ${detail}${redirect}.`, + ); + } else { + logger.warn(`[dispatcher] Falló la entrega a ${subscription.webhookId} (${subscription.url}): ${detail}${redirect}.`); + } + + for (const row of deliverableRows) { + if (result.ok) await evn1.markDelivered(client, row.code, result.status); + else await evn1.markFailedAttempt(client, row, result.status); + } +} + +async function recomputeEventStatuses(client: ServiceLayerClient, eventIds: string[]): Promise { + for (const eventId of eventIds) { + const rows = await evn1.listForEvent(client, eventId); + if (rows.length === 0) continue; + const allDelivered = rows.every((r) => r.status === 'D'); + await oevn.setEventStatus(client, eventId, allDelivered ? 'D' : 'P'); + } +} diff --git a/src/dispatcher/send-webhook.ts b/src/dispatcher/send-webhook.ts new file mode 100644 index 0000000..136ccec --- /dev/null +++ b/src/dispatcher/send-webhook.ts @@ -0,0 +1,51 @@ +import axios from 'axios'; +import https from 'node:https'; +import { createHmac } from 'node:crypto'; +import type { CloudEvent } from '../lib/cloud-event.ts'; +import type { Subscription } from '../subscriptions/subscriptions-repository.ts'; + +const http = axios.create({ + httpsAgent: new https.Agent({ rejectUnauthorized: false }), + validateStatus: () => true, +}); + +export interface SendResult { + ok: boolean; + status: number; + // Respuesta cruda recortada. Un 2xx no prueba que el receptor haya + // procesado el evento: hay destinos que encolan y devuelven 202 con un id + // de ejecución, y sin este dato no hay forma de rastrear qué pasó después. + body: string; + // URL efectiva tras seguir redirects. Si difiere de subscription.url, el + // POST pudo haberse degradado a GET y perdido el body por el camino. + finalUrl: string; +} + +const MAX_BODY_LOG = 300; + +function summarizeBody(data: unknown): string { + const text = typeof data === 'string' ? data : JSON.stringify(data) ?? ''; + return text.length > MAX_BODY_LOG ? `${text.slice(0, MAX_BODY_LOG)}…` : text; +} + +// Mismo esquema de firma que ya implementa sap-webhook-api +// (X-B1-Webhook-Signature, HMAC-SHA256 en base64 sobre los bytes crudos del +// body) — el AuthCred de una suscripción HMAC es el JSON embebido que usa +// SAP internamente: {"Key": "", "Algorithm": "hmacsha256"}. +export async function sendBatch(subscription: Subscription, events: CloudEvent[]): Promise { + const body = JSON.stringify(events); + const headers: Record = { 'Content-Type': 'application/json' }; + + if (subscription.authType === 'H' && subscription.authCred) { + const { Key } = JSON.parse(subscription.authCred) as { Key: string }; + headers['X-B1-Webhook-Signature'] = createHmac('sha256', Key).update(body).digest('base64'); + } + + const res = await http.post(subscription.url, body, { headers }); + return { + ok: res.status >= 200 && res.status < 300, + status: res.status, + body: summarizeBody(res.data), + finalUrl: (res.request as { res?: { responseUrl?: string } })?.res?.responseUrl ?? subscription.url, + }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f72f3e4 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,27 @@ +import cron from 'node-cron'; +import { config } from './config.ts'; +import { ServiceLayerClient } from './service-layer-client.ts'; +import { processTick } from './dispatcher/process-tick.ts'; +import { logger } from './lib/logger.ts'; + +const client = new ServiceLayerClient(config.serviceLayer); + +async function runOnce(): Promise { + try { + await processTick(client); + } catch (err) { + logger.error('[dispatcher] Error procesando el tick:', err); + } +} + +const runOnceOnly = process.argv.includes('--once'); + +if (runOnceOnly) { + await runOnce(); + process.exit(0); +} else { + const cronExpression = `*/${config.pollIntervalSeconds} * * * * *`; + logger.info(`[dispatcher] sap-legacy-webhook-emulator arrancó. Revisando @OEVN cada ${config.pollIntervalSeconds}s.`); + cron.schedule(cronExpression, runOnce); + await runOnce(); // primera corrida inmediata, sin esperar el primer tick +} diff --git a/src/lib/cloud-event.ts b/src/lib/cloud-event.ts new file mode 100644 index 0000000..d409764 --- /dev/null +++ b/src/lib/cloud-event.ts @@ -0,0 +1,33 @@ +import { bizObjectFor } from '../mapping/object-types.ts'; +import { operationFor } from '../mapping/trans-types.ts'; +import type { OevnRow } from '../dispatcher/oevn-repository.ts'; + +// Mismo schema CloudEvents que ya implementa sap-webhook-api (ver +// webhooks.controller.ts) — el emulador debe producir un payload +// indistinguible del que manda el Webhook Messenger real. +export interface CloudEvent { + id: string; + specversion: '1.0'; + source: string; + type: string; + subject: string; + time: string; + datacontenttype: 'application/json'; + data: Record; +} + +export function buildCloudEvent(event: OevnRow): CloudEvent { + const bizObject = bizObjectFor(event.objectType); + const operation = operationFor(event.transType); + + return { + id: event.code, + specversion: '1.0', + source: `/default/sap.b1/${event.sourceDb}`, + type: `sap.b1.${bizObject}.${operation}.v1`, + subject: event.fieldValue, + time: new Date().toISOString(), + datacontenttype: 'application/json', + data: { [event.fieldNames]: event.fieldValue }, + }; +} diff --git a/src/lib/codes.ts b/src/lib/codes.ts new file mode 100644 index 0000000..4740f61 --- /dev/null +++ b/src/lib/codes.ts @@ -0,0 +1,9 @@ +import { randomUUID } from 'node:crypto'; + +// Code sintético para filas de nuestros UserTable creadas desde Node +// (@EVC1, @EVN1 — ver docs/DISENO.md, decisión 5): nunca es el +// identificador de negocio real, solo una clave única. Mismo criterio que +// usa el SP en SQL (NEWID() en SQL Server, SYSUUID en HANA). +export function generateCode(): string { + return randomUUID(); +} diff --git a/src/lib/logger.ts b/src/lib/logger.ts new file mode 100644 index 0000000..4bb17f9 --- /dev/null +++ b/src/lib/logger.ts @@ -0,0 +1,36 @@ +import { appendFileSync, mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +// Log a archivo, uno por día (logs/dispatcher-YYYY-MM-DD.log), además de +// consola — para que un servicio Windows (NSSM/PM2/Task Scheduler, ver +// README) no dependa de que algo capture su stdout. Formato de línea +// simple (timestamp ISO + nivel entre corchetes) para que lo pueda leer +// cualquier visualizador de logs genérico (ej. lnav, o un `tail -f`). +const LOGS_DIR = fileURLToPath(new URL('../../logs/', import.meta.url)); +mkdirSync(LOGS_DIR, { recursive: true }); + +function logFilePath(date: Date): string { + const day = date.toISOString().slice(0, 10); + return `${LOGS_DIR}dispatcher-${day}.log`; +} + +function write(level: string, message: string): void { + const now = new Date(); + appendFileSync(logFilePath(now), `${now.toISOString()} [${level}] ${message}\n`); +} + +export const logger = { + info(message: string): void { + console.log(message); + write('INFO', message); + }, + warn(message: string): void { + console.warn(message); + write('WARN', message); + }, + error(message: string, err?: unknown): void { + const detail = err instanceof Error ? (err.stack ?? err.message) : err !== undefined ? String(err) : ''; + console.error(message, err ?? ''); + write('ERROR', detail ? `${message} ${detail}` : message); + }, +}; diff --git a/src/lib/service-layer-paths.ts b/src/lib/service-layer-paths.ts new file mode 100644 index 0000000..1f3e998 --- /dev/null +++ b/src/lib/service-layer-paths.ts @@ -0,0 +1,8 @@ +// El EntitySet que expone Service Layer para leer/escribir FILAS de un +// UserTable es "U_" (con el prefijo "U_") — distinto del nombre +// que usan los endpoints de metadata (UserTablesMD/UserFieldsMD, que usan +// el TableName sin prefijo). Comportamiento real confirmado contra Service +// Layer; no está documentado en ningún lado consultado durante el diseño. +export function userTableEntity(tableName: string): string { + return `U_${tableName}`; +} diff --git a/src/lib/timestamps.ts b/src/lib/timestamps.ts new file mode 100644 index 0000000..84adb3f --- /dev/null +++ b/src/lib/timestamps.ts @@ -0,0 +1,6 @@ +// Formato entero HHMMSS usado por los campos U_LastSentTM (mismo criterio +// que los timestamps nativos de SAP, sin los milisegundos que sí tiene el +// OEVN real — ver docs/DISENO.md, diccionario de datos). +export function hhmmss(date: Date): number { + return date.getHours() * 10000 + date.getMinutes() * 100 + date.getSeconds(); +} diff --git a/src/mapping/object-types.ts b/src/mapping/object-types.ts new file mode 100644 index 0000000..f41c49a --- /dev/null +++ b/src/mapping/object-types.ts @@ -0,0 +1,33 @@ +// Mapeo ObjectType -> BizObject: nombre del member de BoObjectTypes (SDK de +// SAP Business One) sin el prefijo "o". Confirmado con el INSERT real +// capturado por trace (ObjectType='17' -> BizObject='Orders'). Subconjunto +// curado, no las ~150 entradas de BoObjectTypes — agregar acá a medida que +// se necesiten nuevos BusinessObject. Ver docs/DISENO.md. +export const OBJECT_TYPE_TO_BIZ_OBJECT: Record = { + '1': 'ChartOfAccounts', + '2': 'BusinessPartners', + '4': 'Items', + '13': 'Invoices', + '14': 'CreditNotes', + '15': 'DeliveryNotes', + '16': 'Returns', + '17': 'Orders', + '18': 'PurchaseInvoices', + '19': 'PurchaseCreditNotes', + '20': 'PurchaseDeliveryNotes', + '21': 'PurchaseReturns', + '22': 'PurchaseOrders', + '23': 'Quotations', + '24': 'IncomingPayments', + '30': 'JournalEntries', + '46': 'VendorPayments', + '53': 'SalesPersons', + '67': 'StockTransfer', + '202': 'ProductionOrders', +}; + +export function bizObjectFor(objectType: string): string { + const bizObject = OBJECT_TYPE_TO_BIZ_OBJECT[objectType]; + if (!bizObject) throw new Error(`ObjectType desconocido: '${objectType}' (agregarlo a object-types.ts)`); + return bizObject; +} diff --git a/src/mapping/trans-types.ts b/src/mapping/trans-types.ts new file mode 100644 index 0000000..ce63899 --- /dev/null +++ b/src/mapping/trans-types.ts @@ -0,0 +1,14 @@ +// Mapeo TransType -> Operation, confirmado contra la definición real de la +// tabla EVC1 de SAP. Ver docs/DISENO.md. +export const TRANS_TYPE_TO_OPERATION: Record = { + A: 'Created', + U: 'Updated', + D: 'Deleted', + C: 'Canceled', + O: 'Reopened', + L: 'Closed', +}; + +export function operationFor(transType: string): string { + return TRANS_TYPE_TO_OPERATION[transType] ?? transType; +} diff --git a/src/provisioning/payloads.ts b/src/provisioning/payloads.ts new file mode 100644 index 0000000..186c052 --- /dev/null +++ b/src/provisioning/payloads.ts @@ -0,0 +1,54 @@ +import type { FieldDefinition, TableDefinition } from './table-definitions.ts'; + +// Traducción de FieldDefinition/TableDefinition (nuestro esquema abstracto, +// ver table-definitions.ts) a los bodies reales de Service Layer +// (UserTablesMD/UserFieldsMD). Única fuente de verdad para esa forma — +// la usan tanto user-tables.ts (provisioning real) como +// scripts/export-table-json.ts (export a JSON para revisión o POST manual). +const FIELD_TYPE_TO_SAP: Record = { + Alpha: 'db_Alpha', + Numeric: 'db_Numeric', + Date: 'db_Date', +}; + +export interface UserTablePayload { + TableName: string; + TableDescription: string; + TableType: 'bott_NoObject'; +} + +export interface UserFieldPayload { + TableName: string; + Name: string; + Description: string; + Type: string; + Size?: number; + EditSize?: number; +} + +export function toUserTablePayload(definition: TableDefinition): UserTablePayload { + return { + TableName: definition.tableName, + TableDescription: definition.description, + TableType: 'bott_NoObject', + }; +} + +export function toUserFieldPayload(tableName: string, field: FieldDefinition): UserFieldPayload { + return { + // Comportamiento real confirmado contra Service Layer: a diferencia de + // UserTablesMD.TableName (sin "@"), UserFieldsMD.TableName SÍ necesita + // el nombre físico con "@" — si no, Service Layer responde "Table not + // found" aunque la tabla exista. + TableName: `@${tableName}`, + Name: field.name, + Description: field.name, + Type: FIELD_TYPE_TO_SAP[field.type], + // Sin Size/EditSize explícito, un campo db_Numeric queda limitado en la + // práctica a 4 dígitos (aunque la metadata reporte Size=6 por default) + // — confirmado real: valores de 5+ dígitos se pierden en silencio en + // el PATCH (sin error, quedan null). Por eso Numeric también manda + // Size/EditSize, igual que Alpha — nunca confiar en el default. + ...(field.type === 'Alpha' || field.type === 'Numeric' ? { Size: field.size, EditSize: field.size } : {}), + }; +} diff --git a/src/provisioning/run-provision.ts b/src/provisioning/run-provision.ts new file mode 100644 index 0000000..cab1877 --- /dev/null +++ b/src/provisioning/run-provision.ts @@ -0,0 +1,11 @@ +import { config } from '../config.ts'; +import { ServiceLayerClient } from '../service-layer-client.ts'; +import { TABLE_DEFINITIONS } from './table-definitions.ts'; +import { ensureUserTable } from './user-tables.ts'; + +const client = new ServiceLayerClient(config.serviceLayer); + +for (const definition of TABLE_DEFINITIONS) { + await ensureUserTable(client, definition); + console.log(`[provision] Tabla @${definition.tableName} verificada/creada.`); +} diff --git a/src/provisioning/table-definitions.ts b/src/provisioning/table-definitions.ts new file mode 100644 index 0000000..73ab76a --- /dev/null +++ b/src/provisioning/table-definitions.ts @@ -0,0 +1,73 @@ +// Definición declarativa de las 4 UserTablesMD del emulador. El esquema +// real de SAP y las decisiones que llevaron a este recorte de campos están +// documentadas en docs/DISENO.md ("Diccionario de datos"). +export type FieldType = 'Alpha' | 'Numeric' | 'Date'; + +export interface FieldDefinition { + name: string; + type: FieldType; + size?: number; // requerido para 'Alpha' y 'Numeric' (ver payloads.ts: sin Size explícito, Numeric queda limitado a 4 dígitos) +} + +export interface TableDefinition { + tableName: string; // sin el prefijo "@" — SAP lo antepone solo + description: string; // UserTablesMD.TableDescription: máx. 30 caracteres (confirmado contra Service Layer real) + fields: FieldDefinition[]; +} + +export const TABLE_DEFINITIONS: TableDefinition[] = [ + { + tableName: 'OEVC', + description: 'Webhook Subscription', + fields: [ + { name: 'WebhookURL', type: 'Alpha', size: 254 }, + { name: 'AuthType', type: 'Alpha', size: 1 }, + { name: 'AuthCred', type: 'Alpha', size: 254 }, + { name: 'Handshake', type: 'Alpha', size: 1 }, + { name: 'VerifyCert', type: 'Alpha', size: 1 }, + { name: 'State', type: 'Alpha', size: 1 }, + { name: 'WorkMode', type: 'Alpha', size: 1 }, + { name: 'LastSentDT', type: 'Date' }, + { name: 'LastSentTM', type: 'Numeric', size: 9 }, + { name: 'LastErrMsg', type: 'Alpha', size: 254 }, + ], + }, + { + tableName: 'EVC1', + description: 'Webhook Subscription Filter', + fields: [ + { name: 'WebhookID', type: 'Alpha', size: 40 }, + { name: 'BizObject', type: 'Alpha', size: 64 }, + { name: 'TransType', type: 'Alpha', size: 1 }, + { name: 'ObjectType', type: 'Alpha', size: 30 }, + ], + }, + { + tableName: 'OEVN', + description: 'Webhook Event', + fields: [ + { name: 'SourceDB', type: 'Alpha', size: 100 }, + { name: 'Status', type: 'Alpha', size: 1 }, + { name: 'ObjectType', type: 'Alpha', size: 30 }, + { name: 'TransType', type: 'Alpha', size: 1 }, + { name: 'FieldsInKe', type: 'Numeric', size: 6 }, + { name: 'FieldNames', type: 'Alpha', size: 254 }, + { name: 'FieldValue', type: 'Alpha', size: 254 }, + { name: 'ReplayStat', type: 'Alpha', size: 1 }, + { name: 'UserID', type: 'Alpha', size: 25 }, + ], + }, + { + tableName: 'EVN1', + description: 'Webhook Event Delivery', + fields: [ + { name: 'EventID', type: 'Alpha', size: 40 }, + { name: 'WebhookID', type: 'Alpha', size: 40 }, + { name: 'Status', type: 'Alpha', size: 1 }, + { name: 'Retry', type: 'Numeric', size: 6 }, + { name: 'ReturnCode', type: 'Numeric', size: 9 }, + { name: 'LastSentDT', type: 'Date' }, + { name: 'LastSentTM', type: 'Numeric', size: 9 }, + ], + }, +]; diff --git a/src/provisioning/user-tables.ts b/src/provisioning/user-tables.ts new file mode 100644 index 0000000..cef141e --- /dev/null +++ b/src/provisioning/user-tables.ts @@ -0,0 +1,32 @@ +import type { ServiceLayerClient } from '../service-layer-client.ts'; +import type { FieldDefinition, TableDefinition } from './table-definitions.ts'; +import { toUserFieldPayload, toUserTablePayload } from './payloads.ts'; + +// Idempotente: crea la UserTable y sus UserFields solo si todavía no existen, +// para poder correr `npm run provision` tantas veces como haga falta. +export async function ensureUserTable(client: ServiceLayerClient, definition: TableDefinition): Promise { + if (!(await tableExists(client, definition.tableName))) { + await client.post('/UserTablesMD', toUserTablePayload(definition)); + } + for (const field of definition.fields) { + await ensureUserField(client, definition.tableName, field); + } +} + +async function tableExists(client: ServiceLayerClient, tableName: string): Promise { + const res = await client.get(`/UserTablesMD('${tableName}')`); + return res.status === 200; +} + +async function ensureUserField(client: ServiceLayerClient, tableName: string, field: FieldDefinition): Promise { + if (await fieldExists(client, tableName, field.name)) return; + await client.post('/UserFieldsMD', toUserFieldPayload(tableName, field)); +} + +async function fieldExists(client: ServiceLayerClient, tableName: string, fieldName: string): Promise { + // El campo, una vez creado, queda registrado con TableName="@" + // (ver la nota en toUserFieldPayload) — hay que filtrar igual. + const filter = encodeURIComponent(`TableName eq '@${tableName}' and Name eq '${fieldName}'`); + const res = await client.get(`/UserFieldsMD?$filter=${filter}`); + return res.status === 200 && Array.isArray(res.data?.value) && res.data.value.length > 0; +} diff --git a/src/service-layer-client.ts b/src/service-layer-client.ts new file mode 100644 index 0000000..b9d15a2 --- /dev/null +++ b/src/service-layer-client.ts @@ -0,0 +1,90 @@ +import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; +import https from 'node:https'; +import type { ServiceLayerTarget } from './config.ts'; + +// Cliente mínimo del Service Layer: Login + GET/POST/PATCH/DELETE con la +// cookie de sesión. No cachea la sesión entre corridas (cada ciclo del +// dispatcher vuelve a loguearse) para no lidiar con expiración de +// B1SESSION (~30 min) — mismo criterio que sap-intercompany-worker. +export class ServiceLayerClient { + private readonly http: AxiosInstance; + private loggedIn = false; + + constructor(private readonly target: ServiceLayerTarget) { + this.http = axios.create({ + baseURL: target.baseUrl, + httpsAgent: new https.Agent({ rejectUnauthorized: false }), + validateStatus: () => true, // manejamos los status code nosotros, sin que axios tire excepción + }); + } + + async login(): Promise { + const res = await this.http.post('/Login', { + CompanyDB: this.target.companyDb, + UserName: this.target.username, + Password: this.target.password, + }); + if (res.status !== 200) { + throw new Error(`Login falló en ${this.target.baseUrl}: HTTP ${res.status} ${JSON.stringify(res.data)}`); + } + const setCookie = res.headers['set-cookie']; + if (setCookie) { + this.http.defaults.headers.common['Cookie'] = setCookie.map((c) => c.split(';')[0]).join('; '); + } + this.loggedIn = true; + } + + private async ensureLoggedIn(): Promise { + if (!this.loggedIn) await this.login(); + } + + // GET se usa a menudo para chequear existencia (404 es una respuesta + // válida, no un error) — se deja sin validar, cada llamador mira el + // status que necesita. + async get(path: string): Promise { + await this.ensureLoggedIn(); + return this.http.get(path); + } + + // Service Layer pagina las listas de a 20 registros por defecto + // (`odata.nextLink`) — confirmado real: con más de 20 eventos + // pendientes, un GET sin paginar devuelve solo los primeros 20 y + // esconde el resto en silencio. Usar SIEMPRE que se necesite la lista + // completa (no para chequeos de existencia de una sola fila). + async getAll(path: string): Promise[]> { + const results: Record[] = []; + let next: string | undefined = path; + while (next) { + const res = await this.get(next); + results.push(...((res.data?.value as Record[]) ?? [])); + const nextLink = res.data?.['odata.nextLink'] as string | undefined; + next = nextLink ? `/${nextLink}` : undefined; + } + return results; + } + + async post(path: string, body: unknown): Promise { + await this.ensureLoggedIn(); + return this.assertOk(await this.http.post(path, body)); + } + + async patch(path: string, body: unknown): Promise { + await this.ensureLoggedIn(); + return this.assertOk(await this.http.patch(path, body)); + } + + async delete(path: string): Promise { + await this.ensureLoggedIn(); + return this.assertOk(await this.http.delete(path)); + } + + // POST/PATCH/DELETE son siempre una acción con una intención clara: si + // Service Layer responde error, tiene que ser ruidoso (excepción), nunca + // una fila que "parece" creada/actualizada pero no lo está. + private assertOk(res: AxiosResponse): AxiosResponse { + if (res.status < 200 || res.status >= 300) { + throw new Error(`Service Layer respondió HTTP ${res.status}: ${JSON.stringify(res.data)}`); + } + return res; + } +} diff --git a/src/subscriptions/subscriptions-repository.ts b/src/subscriptions/subscriptions-repository.ts new file mode 100644 index 0000000..51f38c9 --- /dev/null +++ b/src/subscriptions/subscriptions-repository.ts @@ -0,0 +1,56 @@ +import type { ServiceLayerClient } from '../service-layer-client.ts'; +import { userTableEntity } from '../lib/service-layer-paths.ts'; + +const OEVC = userTableEntity('OEVC'); +const EVC1 = userTableEntity('EVC1'); + +export interface Subscription { + webhookId: string; // = Code + url: string; + authType: string; + authCred: string | null; + state: string; +} + +export interface SubscriptionFilter { + webhookId: string; + bizObject: string; + transType: string; +} + +function toSubscription(raw: Record): Subscription { + return { + webhookId: raw.Code as string, + url: raw.U_WebhookURL as string, + authType: raw.U_AuthType as string, + authCred: (raw.U_AuthCred as string) ?? null, + state: raw.U_State as string, + }; +} + +function toFilter(raw: Record): SubscriptionFilter { + return { + webhookId: raw.U_WebhookID as string, + bizObject: raw.U_BizObject as string, + transType: raw.U_TransType as string, + }; +} + +// Devuelve un mapa WebhookID -> Subscription, solo las activas (State='A'). +export async function listActiveSubscriptions(client: ServiceLayerClient): Promise> { + const rows = await client.getAll(`/${OEVC}?$filter=${encodeURIComponent("U_State eq 'A'")}`); + const subscriptions = rows.map(toSubscription); + return new Map(subscriptions.map((s) => [s.webhookId, s])); +} + +// Todos los filtros de @EVC1 — se trae completo y se matchea en memoria +// contra BizObject/TransType, en vez de depender de operadores $filter más +// avanzados que Service Layer podría no soportar igual entre motores. +export async function listFilters(client: ServiceLayerClient): Promise { + const rows = await client.getAll(`/${EVC1}`); + return rows.map(toFilter); +} + +export async function markExceptional(client: ServiceLayerClient, webhookId: string): Promise { + await client.patch(`/${OEVC}('${webhookId}')`, { U_State: 'X' }); +} diff --git a/tools/devolucion.http b/tools/devolucion.http new file mode 100644 index 0000000..6422573 --- /dev/null +++ b/tools/devolucion.http @@ -0,0 +1,51 @@ +@url=https://hw-cloud-su2.dot-solutions.cloud:50000/b1s/v1 + + +### +# @no-reject-unauthorized +POST {{url}}/Login +Content-Type: application/json + +{ + "CompanyDB": "SBOTSTLIBROS", + "UserName": "dot-solutions\\dot.zmarcano", + "Password": "" +} + +### +# @no-reject-unauthorized + +GET {{url}}/PurchaseDeliveryNotes?$select=DocEntry,DocNum,DocCurrency,DocDate,CardCode,CardName,&$filter=CardCode eq '93250000-0P'and DocumentStatus eq 'bost_Open' and DocType eq 'dDocument_Items' + +### +# @no-reject-unauthorized + +GET {{url}}/PurchaseDeliveryNotes(4020) + +### +# @no-reject-unauthorized + +POST {{url}}/PurchaseReturns +Content-Type: application/json + +{ + "DocType": "dDocument_Items", + "CardCode": "93250000-0P", + "DocDate": "2026-09-09T00:00:00Z", + "DocCurrency": "CLP", + "DocumentLines":[ + { + "BaseType":20, + "BaseEntry":477, + "BaseLine":0, + "Quantity":1 + }, + { + "BaseType":20, + "BaseEntry":4020, + "BaseLine":0, + "Quantity":2 + } + ] + +} \ No newline at end of file diff --git a/tools/sap.http b/tools/sap.http new file mode 100644 index 0000000..0024c23 --- /dev/null +++ b/tools/sap.http @@ -0,0 +1,51 @@ +@url=https://hw-cloud-su2.dot-solutions.cloud:50000/b1s/v1 + + +### +# @no-reject-unauthorized +POST {{url}}/Login +Content-Type: application/json + +{ + "CompanyDB": "SBOTSTLIBROS", + "UserName": "dot-solutions\\dot.zmarcano", + "Password": "" +} + +### +# @no-reject-unauthorized + +GET {{url}}/Items('8435174921233F') + + +### +# @no-reject-unauthorized + +PATCH {{url}}/Items('8435174921233F') +Content-Type: application/json + +{ + "ForeignName":"MONSTRUO DE COLORES - SL6", + "BarCode": "8435174921233" +} + +### +# @no-reject-unauthorized + +GET {{url}}/BusinessPartners('76039758-KP') + +### +# @no-reject-unauthorized + +PATCH {{url}}/BusinessPartners('76039758-KP') +Content-Type: application/json + +{ + "Notes":"ACTUALIZADO POR SL", + "MailAddress": "correo@corre.cl" +} + +### +# @no-reject-unauthorized + +GET {{url}}/Orders(8) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7c3385f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node"], + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +}