commit ca57403663f8c60dc1fe04cf8f727de6906f2cd1 Author: George Date: Wed Sep 16 07:45:57 2026 -0500 Initial commit: sap-intercompany-worker Motor de escenarios de replicacion entre bases SAP Business One (Service Layer). Poll periodico sobre webhook_events, escenarios configurables via Prisma (filter + transform por escenario). Co-Authored-By: Claude Sonnet 5 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a821b0e --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +DATABASE_URL="postgresql://intercompany:intercompany@localhost:5432/sap_intercompany?schema=public" + +# Cada cuántos segundos el worker revisa eventos pendientes +POLL_INTERVAL_SECONDS=30 + +# Las bases SAP (incluida la casa matriz) y los escenarios de replicación ya +# NO se configuran acá: viven en Postgres. Ver scripts/manage-config.ts +# (database:add, scenario:add, etc.) y docs/ESCENARIOS.md para la guía. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..126419d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +# Keep environment variables out of version control +.env + +/src/generated/prisma diff --git a/README.md b/README.md new file mode 100644 index 0000000..4bd6d31 --- /dev/null +++ b/README.md @@ -0,0 +1,117 @@ +# sap-intercompany-worker + +Worker que replica datos maestros entre bases de SAP Business One (Service +Layer), a partir de **escenarios** configurables — no hay una casa matriz +fija ni una lista fija de subsidiarias: cualquier base registrada puede ser +origen o destino, según lo que declare cada escenario. + +## Qué hace + +Cada `POLL_INTERVAL_SECONDS` (default 30s), revisa la tabla `webhook_events` +en Postgres — la misma que llena [`sap-webhook-api`](../sap-webhook-api) al +recibir un webhook de SAP — buscando eventos pendientes. Por cada uno: + +1. Busca los **escenarios activos** que aplican a ese `businessObject` + (puede haber más de uno, ej. "clientes" y "proveedores" sobre + `BusinessPartners`, cada uno con su propio filtro), y descarta los que + no vinieron realmente de la base origen que ese escenario declara (ver + protección contra loops en `docs/ARQUITECTURA.md`). +2. Lee el registro completo desde la base origen y lo pasa por el `filter` + del escenario (`src/scenarios/.ts`) — si no aplica, sigue con el + próximo. Si aplica, por cada destino lo pasa por `transform` (con acceso + a mapeos de valor cargados en Postgres vía `ctx.mapValue`) y resuelve si + ya existe en ese destino — por clave directa o por un UDF de + correspondencia, según el `keyStrategy` del escenario — para decidir + `PATCH` o `POST`. +3. Registra cada intento (escenario, verbo usado, HTTP status, error, y el + **payload ya transformado enviado**) en `replication_attempts`. +4. Calcula el estado final del evento sobre la suma de todos los intentos + de todos los escenarios que aplicaron: `done` / `partial` / `failed` / + `filtered` (ningún escenario aceptó el mensaje). + +Es un proceso separado de `sap-webhook-api` a propósito: SAP tiene un +timeout de 10s para la respuesta del webhook, y replicar hacia varios +destinos (con sus propios logins y llamadas) puede tardar más que eso — por +eso el trabajo pesado corre acá, a su propio ritmo, sin poner en riesgo la +suscripción del webhook. + +Ver [`docs/ARQUITECTURA.md`](./docs/ARQUITECTURA.md) para el detalle +completo del diseño (por qué escenarios y no reglas fijas, `keyStrategy`, +diagrama de flujo, reglas de agregación de estado), +[`docs/ESCENARIOS.md`](./docs/ESCENARIOS.md) para la guía paso a paso de +cómo configurar uno con ejemplos reales, y +[`docs/FLUJO.md`](./docs/FLUJO.md) para el diagrama de secuencia +archivo-por-archivo, función por función, de todo el camino de un mensaje +(incluye `sap-webhook-api`). + +**Implementado**: datos maestros con destino fijo (`destinationMode = 'fixed'` +— `BP_PROVEEDORES`, `CHARTOFACCOUNTS`, `PROFITCENTERS`, `SALESPERSONS`) y +transacciones con destino resuelto desde el propio mensaje +(`destinationMode = 'payload'` — `JOURNALENTRIES`, primer caso real). Un +escenario también puede declarar un `destinationBusinessObject` distinto al +de origen (ej. `PurchaseOrders` → `Orders`), para cuando el objeto cambia +de nombre entre bases. + +**Trabajo futuro**: `sap-intercompany-viewer` (frontend) + +`sap-intercompany-api` (backend nuevo, dueño de `databases`/`scenarios`/ +`scenario_destinations`/`scenario_value_mappings`/`webhook_events`/ +`replication_attempts` vía HTTP, ya que este worker no expone HTTP a +propósito) — visor único de eventos/log/escenarios con reproceso, en +reemplazo de `sap-webhook-viewer`. + +## Quick start + +```bash +npm install +cp .env.example .env # completar DATABASE_URL / POLL_INTERVAL_SECONDS + +# Postgres: instancia nativa (Homebrew) en localhost:5432 — ya no se usa +# Docker para esto (docker-compose.yml queda sin uso; si tenés un Postgres +# corriendo por Homebrew/apt en el mismo puerto, ojo con el conflicto: +# localhost prioriza la instancia nativa sobre un contenedor Docker). +npx prisma migrate deploy + +# Registrar bases y al menos un escenario antes de correr el worker +# (ver docs/ESCENARIOS.md para la guía completa con ejemplos reales) +npx tsx scripts/manage-config.ts database:add HQ01 "Casa Matriz" +npx tsx scripts/manage-config.ts scenario:add HQ01 +npx tsx scripts/manage-config.ts scenario:add-destination +# editar src/scenarios/.ts (se generó solo con la primera línea) + +npm run run-once # una sola pasada, útil para probar +npm run start # corre en loop +``` + +Sin destinos configurados para un escenario, el worker igual corre: lee el +registro de la base origen y deja el evento en `failed` (0 intentos), sin +romper — útil para probar el flujo antes de tener todas las bases reales. + +## Estructura + +``` +prisma/schema.prisma # modelos (ver docs/TABLAS.md) +docs/ + ARQUITECTURA.md # conceptos, keyStrategy, diagrama de flujo, reglas de estado + TABLAS.md # referencia de columnas por tabla, con ejemplos reales + ESCENARIOS.md # guía paso a paso con 5 ejemplos reales configurados + FLUJO.md # diagrama de secuencia + funciones principales por archivo +scripts/manage-config.ts # CLI de administración (databases, scenarios, destinos, mapeos) +src/ + config.ts # solo POLL_INTERVAL_SECONDS; todo lo demás vive en Postgres + serviceLayer.ts # cliente mínimo del Service Layer (Login/GET/POST/PATCH) + replicationConfig.ts # carga de config activa desde Postgres en cada tick + scenarioTransform.ts # carga/ejecuta filter+transform de un escenario + ctx.mapValue + replicateEvents.ts # motor: resuelve N escenarios por evento (datos maestros y transacciones) + scenarios/ # un archivo .ts por escenario (filter + transform) + index.ts # cron / --once +``` + +## Stack + +- Script plano en TypeScript (ESM), ejecutado con `tsx` — sin framework, + porque no expone HTTP. +- Prisma 7 + `@prisma/adapter-pg` contra Postgres. +- `node-cron` para el loop periódico. + +Contexto completo de por qué se armó así (decisiones de arquitectura, +alternativas descartadas) en `CLAUDE.md`, en la raíz del repo. diff --git a/docs/ARQUITECTURA.md b/docs/ARQUITECTURA.md new file mode 100644 index 0000000..1398075 --- /dev/null +++ b/docs/ARQUITECTURA.md @@ -0,0 +1,218 @@ +# Arquitectura: motor de escenarios + +## Por qué existe + +La primera versión de este worker tenía una regla fija por cada `businessObject` +recibido del webhook (una sola por objeto, aplicada siempre, hacia todas las +subsidiarias activas). Eso alcanzaba mientras solo existía **un** caso de uso +por objeto. En cuanto aparece más de una forma de tratar el mismo objeto — +por ejemplo, replicar los **clientes** de `BusinessPartners` con reglas y +destinos distintos que los **proveedores** — la regla 1:1 no alcanza. + +El motor actual reemplaza esa regla única por **escenarios**: cada uno es una +forma completa e independiente de procesar un `businessObject`, con su +propio filtro, base origen, base(s) destino y transformación de payload. +Puede haber varios escenarios activos para el mismo objeto a la vez, y un +mismo evento puede ser procesado por **más de uno** en paralelo. + +## Conceptos + +### Base de datos (`databases`) + +Registro único de toda compañía SAP alcanzable por Service Layer: URL, +`CompanyDB` y credenciales. **No existe distinción entre "casa matriz" y +"subsidiaria"** — cualquier fila puede ser origen en un escenario y destino +en otro al mismo tiempo. Ver [`TABLAS.md`](./TABLAS.md#databases). + +### Escenario (`scenarios`) + +La unidad central de configuración. Cada escenario define: + +- a qué `businessObject` aplica (`BusinessPartners`, `SalesPersons`, ...) — + también determina con qué se leen los registros del origen, +- opcionalmente, un `destinationBusinessObject` distinto — si no se + especifica, el destino usa el mismo objeto que el origen (el caso + común); se especifica cuando el objeto cambia de nombre entre bases + (ej. origen `PurchaseOrders` → destino `Orders`, comercio intercompañía), +- de qué base lee el registro completo (`originDatabaseCode`), +- qué campo identifica al registro (`keyField`, ej. `CardCode`) y de qué + tipo es (`keyType`: `string` — va entre comillas en la URL OData — o + `number` — va sin comillas; mandar comillas en un campo numérico da + HTTP 400), +- cómo se resuelve si el registro ya existe en el destino (`keyStrategy`, + ver más abajo), +- el modo de resolver destino: `fixed` (lista fija de bases en + `scenario_destinations`, para datos maestros) o `payload` (el destino se + resuelve leyendo un `routingField` del propio mensaje, para + transacciones) — **los dos modos están implementados**, ver el ejemplo + real de `JOURNALENTRIES` en [`ESCENARIOS.md`](./ESCENARIOS.md). + +El filtro (si el escenario aplica a un registro puntual) y la +transformación del payload NO son columnas de esta tabla — son las dos +partes fijas del archivo de código de cada escenario, ver más abajo. + +Ver [`ESCENARIOS.md`](./ESCENARIOS.md) para la guía completa de cómo armar +uno paso a paso, con ejemplos reales. + +### `keyStrategy`: cómo se identifica el mismo registro entre bases + +- **`portable`**: el valor de `keyField` significa lo mismo en origen y + destino (`CardCode`, `CenterCode` — códigos de negocio asignados a mano). + El motor hace `GET` directo por esa clave en el destino. +- **`udfLink`**: SAP asigna la clave por base (ej. `SalesEmployeeCode`, que + autonumera cada compañía) — el mismo vendedor tiene un código distinto en + cada subsidiaria. El escenario declara un `matchField`: un UDF creado a + mano en el destino (ej. `U_HQ_SlpCode`) que guarda el valor de la clave de + origen. El motor busca por ese UDF (`GET .../SalesPersons?$filter=U_HQ_SlpCode eq '5'`) + en vez de por la clave directa: + - si encuentra un registro, usa SU clave propia (la del destino) para el + `PATCH`, + - si no encuentra nada, hace `POST` (con el UDF seteado en el payload + para poder encontrarlo la próxima vez) y guarda la clave que SAP le + asignó en el destino como `resultKey` del intento, para trazabilidad. + +### Destinos de un escenario (`scenario_destinations`) + +Solo aplica a escenarios `destinationMode = 'fixed'`. Es la lista de bases +hacia las que ese escenario replica. Un escenario puede tener una o varias. + +### El archivo del escenario: `src/scenarios/.ts` + +Cada escenario tiene **un solo archivo**, con **siempre las mismas dos +partes**: + +```ts +export function filter(record: Record, ctx: ScenarioFilterContext): boolean | Promise +export function transform(record: Record, ctx: ScenarioTransformContext): Record +``` + +**`filter`** decide si el escenario aplica a ESE registro puntual (`true` = +aplica). Vive en código — no en columnas de la tabla `scenarios` — porque +una condición simple (campo/operador/valor) no alcanza para casos reales +(condiciones compuestas, varios campos a la vez). Se evalúa una sola vez +por evento, contra el registro leído de la base origen, y puede ser +`async`: recibe `ctx.get(path)`, un `GET` arbitrario contra la base ORIGEN +del escenario (reusa la sesión ya autenticada, no abre un login nuevo). +Esto cubre el caso típico de transacciones: una Factura trae el `CardCode` +del cliente en su payload, pero el filtro depende de un campo del +`BusinessPartner` (ej. su `GroupCode`) — el archivo hace +`await ctx.get("/BusinessPartners('${record.CardCode}')")` y decide con +ese dato, sin que el motor tenga que saber nada de esa relación. + +**`transform`** arma el payload a enviar a un destino puntual — se llama +**una vez por destino**, recibe el registro ya leído (limpio de metadata +OData) y un `ctx` con: + +- `ctx.destinationCode`: a qué base le toca este payload (permite + transformar distinto según el destino). +- `ctx.mapValue(fieldName, sourceValue)`: consulta la tabla + `scenario_value_mappings` (ver [`TABLAS.md`](./TABLAS.md)) para ESTE + escenario y ESTE destino. Devuelve `undefined` si no hay mapeo cargado — + el archivo decide qué hacer (tirar error para marcar el intento como + `SKIPPED`, dejar el campo afuera, poner un default, etc.). Esta es la + parte de **mapeo** dentro de la transformación. + +Filtro y transformación viven en código porque necesitan lógica arbitraria +(condicionales, elegir campos) que no es expresable como una tabla de +equivalencias simple — pero los VALORES de los mapeos de campo (ej. +`GroupCode` de HQ → `GroupCode` de cada subsidiaria) sí son datos puros, y +esos viven en Postgres para poder cargarlos con el CLI sin tocar código (y +a futuro, desde una UI). El archivo sigue siendo quien decide QUÉ campo +mapear y QUÉ hacer si falta el valor — la tabla solo guarda el dato. Ver +[`ESCENARIOS.md`](./ESCENARIOS.md) para los 3 ejemplos completos ya +configurados. + +### Intento de replicación (`replication_attempts`) + +Un registro por cada intento de un escenario hacia una base destino +puntual: verbo usado, HTTP status, mensaje de error y el **payload ya +transformado que se envió** (siempre, en éxito y en falla — pensado para +una futura UI de consulta, ver "Trabajo futuro" más abajo). Es un log de +auditoría inmutable: guarda los códigos de base/escenario como texto en el +momento del intento, no relaciones vivas, para no perder trazabilidad si la +configuración cambia o se borra después. + +## Flujo de un evento + +Diagrama de decisión (qué pasa y por qué). Para el diagrama de secuencia +archivo-por-archivo, función por función — incluyendo `sap-webhook-api` — +ver [`FLUJO.md`](./FLUJO.md). + +```mermaid +flowchart TD + A[webhook_events: status=pending] --> B[Tomar evento] + B --> C{Por cada escenario activo
del mismo businessObject} + C --> S{source del evento coincide
con la base origen del escenario?} + S -- no --> C + S -- si --> D[Leer registro completo
desde la base origen del escenario] + D --> E{filter-record- del
archivo del escenario} + E -- false --> C + E -- true --> F[transform-record, ctx-
por cada base destino] + F --> K{keyStrategy} + K -- portable --> G1[GET directo por keyField] + K -- udfLink --> G2[GET por filtro sobre matchField] + G1 --> G[Existe en el destino?] + G2 --> G + G -- si --> H[PATCH] + G -- no --> I[POST + capturar clave asignada] + H --> J[Registrar intento
verbo, status, error, payload] + I --> J + J --> C + C -- todos los escenarios procesados --> L[Calcular status final del evento] +``` + +### Protección contra loops: `source` del evento vs. base origen + +El CloudEvent que manda SAP incluye `"source": "/default/sap.b1/"` +— de qué base salió la notificación. Antes de procesar un evento con un +escenario, el motor compara ese `CompanyDB` contra el de la base origen +configurada (`originDatabase.companyDb`). Si no coincide, el escenario +simplemente no aplica (no genera ningún intento). + +Esto no es cosmético: se descubrió en producción-de-prueba que si una base +DESTINO tiene su propia suscripción de webhook activa (ej. porque se creó +clonando/restaurando la base origen y heredó su configuración), un `PATCH` +nuestro ahí dispara una notificación que, sin este chequeo, el motor +reprocesaría como si fuera un cambio nuevo en el origen — generando un +`PATCH` de nuevo, que dispara otra notificación, indefinidamente. El fix de +fondo es desactivar `EnableWebhook` en las bases que son solo destino, pero +este chequeo queda como segunda capa de protección barata (una comparación +de string) para cualquier otro caso de mala configuración futura. + +### Estado final del evento (`webhook_events.status`) + +Como un evento puede ser tomado por varios escenarios a la vez, el estado +final se calcula sobre **todos** los intentos de **todos** los escenarios +que aplicaron: + +| Estado | Condición | +|---|---| +| `filtered` | Ningún escenario activo para ese `businessObject` aceptó el mensaje (ni siquiera vino de la base origen esperada de ninguno, o todos los filtros lo rechazaron). | +| `done` | Al menos un escenario aplicó, y **todos** los intentos (de todos los escenarios, hacia todos sus destinos) tuvieron éxito. | +| `partial` | Al menos un escenario aplicó, y hay una mezcla de intentos exitosos y fallidos. | +| `failed` | Al menos un escenario aplicó, pero **ningún** intento tuvo éxito (incluye el caso de un escenario sin destinos configurados). | + +## `destinationMode = 'payload'` (transacciones) — implementado + +`processPendingEvents` (en `src/replicateEvents.ts`) procesa ambos modos. En `payload`, no hay filas +en `scenario_destinations` para ese escenario — el destino se resuelve en +runtime leyendo `sourceRecord[scenario.routingField]` (un UDF en el +**origen** cuyo valor es directamente el `code` de la base destino, ej. +`SUB_01` — no el nombre de la compañía SAP). Si el campo viene vacío o no +matchea ninguna base registrada, se registra un intento de error (destino +`'N/A'`) en vez de fallar en silencio. Ejemplo real funcionando: +`JOURNALENTRIES` (ver [`ESCENARIOS.md`](./ESCENARIOS.md) sección 5). + +`keyStrategy=udfLink` es especialmente relevante en modo `payload`: como +cada mensaje puede terminar en una base distinta, sin un UDF de +correspondencia (`matchField`) reprocesar el mismo mensaje generaría un +documento duplicado en el destino cada vez. + +## Trabajo futuro: `sap-intercompany-viewer` + +Proyecto aparte (a construir después de terminar este) para la parte visual +de toda esta configuración: visor de eventos y reenvíos, alta de bases de +datos con prueba de conexión, alta/edición de escenarios, y administración +de sus mapeos de valor. `replication_attempts` ya guarda siempre el payload +enviado (éxito y falla) e incluye índices sobre `event_id`, `scenario_id` y +`attempted_at` pensando en ese consumo futuro. diff --git a/docs/ESCENARIOS.md b/docs/ESCENARIOS.md new file mode 100644 index 0000000..b82559e --- /dev/null +++ b/docs/ESCENARIOS.md @@ -0,0 +1,259 @@ +# Guía: cómo armar un escenario + +Toda la configuración se administra con `scripts/manage-config.ts` (vía +`tsx`), salvo el filtro y la transformación de cada escenario, que son +código. Los cambios tienen efecto en el próximo tick del worker — no hace +falta reiniciar el proceso, tampoco después de editar el archivo del +escenario: el motor lo importa dinámicamente en cada intento. + +**Nombre del archivo**: el `code` de un escenario va en mayúsculas (es el +identificador que ves en `scenario:list`, en los logs y en `--filter`/etc.) +pero su archivo en `src/scenarios/` va en minúsculas — ej. código +`BP_PROVEEDORES` → archivo `bp_proveedores.ts`. `scenario:add` ya genera el +archivo con el nombre correcto; si necesitás renombrar un escenario +después, usá `scenario:rename` (mueve el archivo automáticamente, no lo +hagas a mano). + +## 1. Registrar las bases de datos + +Antes de crear un escenario, las bases que va a usar (origen y destinos) +tienen que existir en `databases`: + +```bash +npx tsx scripts/manage-config.ts database:add HQ01 "HQ DemoCL" https://sap10:50000/b1s/v2 SBODemoCL manager mngr +npx tsx scripts/manage-config.ts database:add SUB_01 "SUB 01 DemoCL" https://sap10:50000/b1s/v2 SUBDemoCL_01 manager mngr +npx tsx scripts/manage-config.ts database:add SUB_02 "SUB 02 DemoCL" https://sap10:50000/b1s/v2 SUBDemoCL_02 manager mngr + +npx tsx scripts/manage-config.ts database:list +``` + +**Importante**: si una base destino se creó clonando/restaurando la base +origen (común en ambientes de demo), puede haber heredado su propia +suscripción de webhook activa. Si el mismo `businessObject` que estás por +replicar dispara un webhook en el destino, hay que desactivarlo +(`EnableWebhook: tNO` en esa compañía) — si no, cada `PATCH`/`POST` nuestro +genera una notificación que, aunque el motor la descarta (ver +`ARQUITECTURA.md` § protección contra loops), sigue entrando como ruido. + +## 2. Ejemplo 1: clave portable + mapeo de valor (`BP_PROVEEDORES`) + +Caso real: `BusinessPartners` con `CardType=cSupplier`, replicando solo +algunos campos, con `GroupCode` traducido por destino. + +### 2.1 Crear el escenario + +```bash +npx tsx scripts/manage-config.ts scenario:add BP_PROVEEDORES "BP - Proveedores" BusinessPartners HQ01 CardCode +``` + +`CardCode` es texto de negocio (igual en HQ01 y en cada subsidiaria), así +que no hace falta `--key-strategy` ni `--key-type` — quedan en sus +defaults (`portable` y `string`). + +Este comando genera automáticamente `src/scenarios/bp_proveedores.ts` (si +no existía) con un `filter`/`transform` de partida (`filter` devuelve +`true` siempre, `transform` devuelve el registro tal cual) — hay que +editarlo con la condición real. + +### 2.2 Definir los destinos + +```bash +npx tsx scripts/manage-config.ts scenario:add-destination BP_PROVEEDORES SUB_01 +npx tsx scripts/manage-config.ts scenario:add-destination BP_PROVEEDORES SUB_02 +``` + +### 2.3 Cargar los mapeos de valor (`GroupCode`) + +`GroupCode` en SAP significa "grupo de clientes" o "grupo de proveedores" +según el `CardType` del registro — son dominios de valores distintos, +aunque el nombre del campo sea el mismo. Por eso el mapeo se carga **por +escenario**, no global: + +```bash +npx tsx scripts/manage-config.ts scenario:mapping:add BP_PROVEEDORES GroupCode SUB_01 101 101 +npx tsx scripts/manage-config.ts scenario:mapping:add BP_PROVEEDORES GroupCode SUB_02 101 101 +# ...un comando por cada (destino, valor de origen) — ver docs/TABLAS.md para la lista completa +``` + +### 2.4 Escribir el filtro y la transformación + +`src/scenarios/bp_proveedores.ts` — el archivo siempre tiene las mismas +dos partes. El filtro es la condición `CardType=cSupplier` (antes vivía +como columnas en la tabla `scenarios`, ahora es código); la transformación +selecciona solo los campos necesarios y resuelve `GroupCode` con +`ctx.mapValue`, no con el valor crudo de HQ01: + +```ts +import type { ScenarioFilterContext, ScenarioTransformContext } from '../scenarioTransform.ts'; + +// --- Filtro: decide si este escenario aplica al registro --- +// No necesita ctx acá: CardType ya viene en el payload de BusinessPartners. +export function filter(record: Record, ctx: ScenarioFilterContext): boolean { + return record.CardType === 'cSupplier'; +} + +// --- Transformación: arma el payload para un destino puntual --- +export function transform(record: Record, ctx: ScenarioTransformContext): Record { + // --- Mapeo: GroupCode vive en Postgres (scenario_value_mappings), no acá --- + const groupCode = record.GroupCode === undefined || record.GroupCode === null ? undefined : String(record.GroupCode); + const mappedGroupCode = groupCode === undefined ? undefined : ctx.mapValue('GroupCode', groupCode); + + if (groupCode !== undefined && mappedGroupCode === undefined) { + throw new Error(`GroupCode '${groupCode}' no tiene mapeo cargado para el destino '${ctx.destinationCode}'`); + } + + return { + CardCode: record.CardCode, + CardName: record.CardName, + CardType: record.CardType, + GroupCode: mappedGroupCode, + Phone1: record.Phone1, + Phone2: record.Phone2, + Notes: record.Notes, + FederalTaxID: record.FederalTaxID, + Currency: record.Currency, + DebitorAccount: record.DebitorAccount, + Valid: record.Valid, + Frozen: record.Frozen, + Properties1: record.Properties1, + }; +} +``` + +Si `filter` devuelve `false`, el escenario no aplica a ese registro (no +genera ningún intento). Si `GroupCode` viene con un valor sin mapeo +cargado dentro de `transform`, el `throw` marca el intento como `SKIPPED` +(no manda nada sin traducir) y el motivo queda en `error_message` de +`replication_attempts`. + +## 3. Ejemplo 2: clave asignada por base (`SALESPERSONS`, `keyStrategy=udfLink`) + +`SalesPersons` usa `SalesEmployeeCode`, que SAP autonumera **por +compañía** — el mismo vendedor tiene un código distinto en cada +subsidiaria. No se puede usar `portable`. + +### 3.1 Crear el UDF en SAP (paso manual, fuera de este repo) + +En cada subsidiaria destino, crear un campo de usuario en la tabla `OSLP` +(Sales Employees) — ej. `U_HQ_SlpCode`, tipo **Alfanumérico** — desde +Herramientas de Personalización / UDF Manager de SAP B1. Sin esto, el +escenario no tiene dónde guardar la correspondencia. + +### 3.2 Crear el escenario + +```bash +npx tsx scripts/manage-config.ts scenario:add SALESPERSONS "SalesPersons" SalesPersons HQ01 SalesEmployeeCode \ + --key-strategy udfLink --match-field U_HQ_SlpCode --key-type number + +npx tsx scripts/manage-config.ts scenario:add-destination SALESPERSONS SUB_01 +npx tsx scripts/manage-config.ts scenario:add-destination SALESPERSONS SUB_03 +``` + +`--key-type number` es necesario porque `SalesEmployeeCode` es numérico en +el Service Layer — mandar la URL con comillas (`SalesPersons('5')`) da +HTTP 400; sin comillas (`SalesPersons(5)`) funciona. + +### 3.3 Cómo lo resuelve el motor (sin que tengas que hacer nada más) + +- Si ya existe un `SalesPersons` en el destino con `U_HQ_SlpCode` igual al + `SalesEmployeeCode` de origen → lo encuentra, usa la clave propia del + destino para el `PATCH`. +- Si no existe ninguno → `POST` (con `U_HQ_SlpCode` seteado en el payload + automáticamente por el motor — no hace falta ponerlo en el `transform`), + y guarda la clave que SAP le asignó en `replication_attempts.result_key` + para la próxima actualización. + +Sin lógica de negocio adicional pedida para este objeto, el `filter` +(`return true`) y el `transform` (identity, `return { ...record }`) +generados por `scenario:add` alcanzan tal cual. + +## 4. Ejemplo 3: sin filtro, a 3 destinos (`PROFITCENTERS`) + +`ProfitCenters` usa `CenterCode`, un código de negocio asignado a mano +(`CC1`, `Stelle_2`) — `portable`, igual que `BusinessPartners`: + +```bash +npx tsx scripts/manage-config.ts scenario:add PROFITCENTERS "ProfitCenters" ProfitCenters HQ01 CenterCode + +npx tsx scripts/manage-config.ts scenario:add-destination PROFITCENTERS SUB_01 +npx tsx scripts/manage-config.ts scenario:add-destination PROFITCENTERS SUB_02 +npx tsx scripts/manage-config.ts scenario:add-destination PROFITCENTERS SUB_03 +``` + +Sin condición pedida: `filter` queda como lo genera el scaffold +(`return true`), aplica a todo `ProfitCenters`. + +## 5. Filtro que necesita un dato que no viene en el payload + +Caso típico de transacciones: una `Invoices` (Factura de Venta) trae el +`CardCode` del cliente, pero el escenario solo debe aplicar si ese cliente +pertenece al `GroupCode` 101. El dato para decidir el filtro no está en el +payload de la Factura — hay que ir a buscarlo a `BusinessPartners`. Por +eso `filter` puede ser `async` y usar `ctx.get(...)` (mismo cliente/sesión +que ya trae el registro principal, sin loguearse de nuevo): + +```ts +import type { ScenarioFilterContext, ScenarioTransformContext } from '../scenarioTransform.ts'; + +// --- Filtro: necesita el GroupCode del cliente, que no viene en la Factura --- +export async function filter(record: Record, ctx: ScenarioFilterContext): Promise { + const cardCode = record.CardCode; + if (typeof cardCode !== 'string') return false; + + const res = await ctx.get(`/BusinessPartners('${cardCode}')`); + if (res.status !== 200) { + throw new Error(`No se pudo leer BusinessPartners('${cardCode}') de ${ctx.originCode}: HTTP ${res.status}`); + } + + return res.data.GroupCode === 101; +} +``` + +Si `ctx.get` tira o devuelve un status inesperado, el `throw` marca el +intento como error (destino `'N/A'`, ver `ARQUITECTURA.md`) — no rompe la +corrida completa, solo ese evento en ese escenario. + +**Importante**: esto resuelve la parte del filtro enriquecido, pero +`Invoices` es una transacción (`destinationMode = 'payload'`), y ese modo +**todavía no está implementado** en el motor (ver limitación en +`ARQUITECTURA.md`) — hace falta además el ruteo del destino desde el +propio mensaje antes de poder usar este escenario de punta a punta. + +## 6. Comandos disponibles (referencia) + +``` +database:add +database:disable +database:list + +scenario:add + [--key-strategy udfLink --match-field U_HQ_SlpCode] [--key-type number] + [--destination-mode payload --routing-field U_ITC_Target] + [--destination-object Orders] +scenario:rename +scenario:disable +scenario:list + +scenario:add-destination +scenario:remove-destination + +scenario:mapping:add +scenario:mapping:list +``` + +`--destination-object` es para cuando el objeto cambia de nombre entre +origen y destino — ej. un escenario que lee `PurchaseOrders` de HQ01 pero +necesita crear un `Orders` (Orden de Venta) en la subsidiaria, típico de +comercio intercompañía. Si se omite, el destino usa el mismo objeto que +`` (el caso de los 5 escenarios de hoy). + +`scenario:add` y `database:add` son upsert por `code`: correr el mismo +comando de nuevo actualiza la fila existente en vez de duplicarla. La +primera vez que se crea un escenario nuevo, además genera su archivo en +`src/scenarios/` con un `filter`/`transform` de partida — no lo sobrescribe +en corridas siguientes. `scenario:rename` cambia el `code` en la DB **y** +mueve el archivo correspondiente en un solo paso — no renombrar el archivo +a mano, porque el motor lo busca por el `code` actual. `scenario:disable` / +`database:disable` desactivan sin borrar — útil para pausar un escenario o +sacar una base de servicio sin perder su configuración ni el historial de +`replication_attempts` que la referencia. diff --git a/docs/FLUJO.md b/docs/FLUJO.md new file mode 100644 index 0000000..caba60c --- /dev/null +++ b/docs/FLUJO.md @@ -0,0 +1,134 @@ +# Flujo de un mensaje, de punta a punta + +Este documento traza el camino completo de una notificación de SAP —desde +que sale del Webhook Messenger hasta que queda replicada en una +subsidiaria— nombrando el archivo y la función concreta que interviene en +cada paso. Complementa a [`ARQUITECTURA.md`](./ARQUITECTURA.md) (conceptos +y reglas de negocio) con la mecánica real de código. + +Dos proyectos intervienen: **`sap-webhook-api`** (recibe el webhook, lo +guarda) y **`sap-intercompany-worker`** (lo procesa y replica) — separados +a propósito por el timeout de 10s de SAP, ver `README.md` de cada uno. + +## Diagrama de secuencia + +```mermaid +sequenceDiagram + participant SAP as SAP (Webhook Messenger) + participant Ctrl as WebhooksController
(sap-webhook-api) + participant Repo as webhook-events.repository
(sap-webhook-api) + participant DB as Postgres
webhook_events + participant Idx as index.ts
(worker, runOnce) + participant Cfg as replicationConfig.ts + participant Eng as replicateEvents.ts
processPendingEvents + participant Mod as scenarioTransform.ts + participant Scn as scenarios/*.ts + participant SL as serviceLayer.ts
ServiceLayerClient + participant Origen as SAP Service Layer
(base origen) + participant Destino as SAP Service Layer
(base destino) + participant Att as Postgres
replication_attempts + + SAP->>Ctrl: POST /webhooks/sap (CloudEvent) + Ctrl->>Ctrl: receiveSapEvent() + Ctrl->>Repo: saveWebhookEventToInbox(notification) + Repo->>DB: INSERT status='pending' + Ctrl-->>SAP: 200 OK + + Note over Idx: cada POLL_INTERVAL_SECONDS (o --once) + Idx->>Cfg: loadReplicationConfig(prisma) + Cfg-->>Idx: databases, scenarios, destinations, valueMappings + Idx->>Eng: processPendingEvents(prisma, cfg) + + Eng->>DB: findMany({status:'pending'}) + DB-->>Eng: eventos pendientes + + loop por cada escenario activo del businessObject + Eng->>Eng: extractSourceCompanyDb(event.payload) + Note right of Eng: ¿source coincide con
la base origen del escenario? + Eng->>SL: new ServiceLayerClient(origen).get(key) + SL->>Origen: Login + GET /{obj}(key) + Origen-->>SL: registro completo + SL-->>Eng: sourceRecord + + Eng->>Mod: loadScenarioModule(code) + Mod-->>Eng: { filter, transform } + Eng->>Mod: buildScenarioFilterContext(origen, ...) + Eng->>Scn: filter(sourceRecord, ctx) + opt el filtro necesita un dato que no viene en el payload + Scn->>SL: ctx.get(path) — reusa la sesión del origen + SL->>Origen: GET (ej. BusinessPartners del CardCode) + Origen-->>SL: registro relacionado + SL-->>Scn: respuesta + end + Scn-->>Eng: true/false + + alt filter() == true + alt destinationMode == fixed + Note right of Eng: destinos = scenario_destinations + else destinationMode == payload + Note right of Eng: destino = sourceRecord[routingField]
(un solo destino, resuelto del mensaje) + end + + loop por cada destino resuelto (uno o varios) + Eng->>Eng: replicateToDestination(...) + Eng->>Mod: buildScenarioTransformContext(dest, scenarioId, valueMappings) + Eng->>Scn: transform(sourceRecord, ctx) + Scn->>Mod: ctx.mapValue(field, sourceValue) + Mod-->>Scn: targetValue | undefined + Scn-->>Eng: payload transformado + + Eng->>SL: new ServiceLayerClient(destino) + Eng->>SL: resolveDestinationRecord(...) + SL->>Destino: GET por keyField o por $filter=matchField + Destino-->>SL: existe / no existe + + alt existe + SL->>Destino: PATCH /{destinationBusinessObject}(destKey) + else no existe + SL->>Destino: POST /{destinationBusinessObject} + end + Destino-->>SL: HTTP status + body + SL-->>Eng: resultado + + Eng->>Att: replicationAttempt.create({verbo, status, error, payloadSent, resultKey}) + end + end + end + + Eng->>DB: webhookEvent.update({status: done|partial|failed|filtered}) +``` + +## Funciones principales por archivo + +### `sap-webhook-api` + +| Archivo | Función | Qué hace | +|---|---|---| +| `src/webhooks/webhooks.controller.ts` | `receiveSapEvent()` | Recibe el `POST /webhooks/sap`, valida HMAC si aplica, guarda el `.json` local y llama al repositorio. | +| `src/webhooks/webhook-events.repository.ts` | `saveWebhookEventToInbox()` | Inserta la fila en `webhook_events` (`status='pending'`). | + +### `sap-intercompany-worker` + +| Archivo | Función | Qué hace | +|---|---|---| +| `src/index.ts` | `runOnce()` | Orquesta un tick: carga config, corre el motor. Se llama por cron o una vez con `--once`. | +| `src/replicationConfig.ts` | `loadReplicationConfig()` | Trae de Postgres las bases, escenarios, destinos y mapeos de valor activos — una sola vez por tick. | +| `src/replicateEvents.ts` | `processPendingEvents()` | El motor: recorre eventos pendientes × escenarios activos (datos maestros y transacciones). Resuelve el/los destino(s) según `destinationMode` — lista fija (`scenario_destinations`) en `fixed`, o `sourceRecord[routingField]` en `payload` — y calcula `destinationBusinessObject` (mismo objeto que el origen salvo que el escenario declare uno distinto). | +| | `replicateToDestination()` | Arma el payload para UN destino puntual, resuelve `PATCH`/`POST` y registra el intento — la misma función sirve para `fixed` y `payload`, ya no importa de dónde salió el código de destino. | +| | `extractSourceCompanyDb()` | Saca el `CompanyDB` del campo `source` del CloudEvent — protección contra loops. | +| | `resolveDestinationRecord()` | Decide si el registro existe en el destino: `GET` directo (`portable`) o por `$filter` sobre `matchField` (`udfLink`). | +| | `formatKeySegment()` / `escapeODataString()` | Arman la URL OData según `keyType` (con o sin comillas) y escapan valores. | +| | `cleanRecord()` | Quita metadata OData (`@odata.context`, etc.) del registro leído. | +| `src/scenarioTransform.ts` | `loadScenarioModule()` | Importa dinámicamente `src/scenarios/.ts` y valida que exporte `filter`/`transform`. | +| | `scenarioFileSlug()` | Deriva el nombre de archivo (minúsculas) a partir del `code` (mayúsculas). | +| | `buildScenarioFilterContext()` | Arma el `ctx` (con `get`) que recibe `filter` — permite un `GET` extra contra la base origen. | +| | `buildScenarioTransformContext()` | Arma el `ctx` (con `mapValue`) que recibe `transform`. | +| | `applyScenarioTransform()` | Ejecuta `transform` y valida que devuelva un objeto JSON. | +| `src/scenarios/.ts` | `filter()` | Decide si el escenario aplica a ESE registro (una vez por evento). Puede ser `async` y usar `ctx.get(path)` si necesita un dato que no viene en el payload. | +| | `transform()` | Arma el payload final para UN destino (una vez por destino), usando `ctx.mapValue` para los mapeos de campo. | +| `src/serviceLayer.ts` | `ServiceLayerClient.login/get/post/patch()` | Cliente mínimo del Service Layer — login lazy, sin cachear sesión entre corridas. | + +## Dónde termina cada mensaje + +- **`webhook_events.status`**: `pending` → `processing` → `done` / `partial` / `failed` / `filtered` (ver la tabla de reglas en `ARQUITECTURA.md`). +- **`replication_attempts`**: una fila por cada destino intentado dentro de cada escenario que aplicó — incluye el payload enviado (éxito y falla) y, para `keyStrategy=udfLink`, la clave real del destino en `result_key`. diff --git a/docs/Integration_Framework_2_0.pdf b/docs/Integration_Framework_2_0.pdf new file mode 100644 index 0000000..eb89d04 Binary files /dev/null and b/docs/Integration_Framework_2_0.pdf differ diff --git a/docs/TABLAS.md b/docs/TABLAS.md new file mode 100644 index 0000000..3b95aa5 --- /dev/null +++ b/docs/TABLAS.md @@ -0,0 +1,156 @@ +# Referencia de tablas + +Todas viven en la base Postgres compartida (`docker-compose.yml` en la raíz +del workspace), definidas en [`prisma/schema.prisma`](../prisma/schema.prisma). +Los ejemplos de esta página usan datos reales de la config actual del +ambiente de desarrollo (bases `HQ01`/`SUB_01`.."SUB_04", escenario +`BP_PROVEEDORES`). + +## `webhook_events` + +Bandeja de entrada. `sap-webhook-api` inserta una fila acá por cada +notificación real que recibe de SAP (además de guardar el `.json` local). +No se toca a mano — el worker es el único que la actualiza. + +| Columna | Tipo | Notas | +|---|---|---| +| `id` | `String` (PK) | El `id` del CloudEvent que manda SAP. | +| `business_object` | `String` | `'BusinessPartners'`, `'SalesPersons'`, `'ProfitCenters'`, ... | +| `operation` | `String` | `'Created'` \| `'Updated'`. | +| `subject` | `String` | Clave del registro en la base que lo generó (`CardCode`, `SalesEmployeeCode`, `CenterCode`, según el objeto). | +| `payload` | `Json` | El CloudEvent completo tal cual llegó — incluye `source` (`/default/sap.b1/`), que el motor usa para la protección contra loops (ver `ARQUITECTURA.md`). | +| `status` | `EventStatus` | `pending` → `processing` → `done` \| `partial` \| `failed` \| `filtered`. | +| `received_at` | `DateTime` | | + +## `databases` + +Registro único de toda base SAP alcanzable por Service Layer. Sin +distinción HQ/subsidiaria — ver [`ARQUITECTURA.md`](./ARQUITECTURA.md). + +| Columna | Tipo | Notas | +|---|---|---| +| `id` | `Int` (PK, autoincremental) | Identificador interno, no se usa como referencia legible. | +| `code` | `String` (único) | Mnemónico corto y **estable**, asignado una sola vez al crear (ej. `HQ01`, `SUB_01`). No autoincremental — es lo que aparece en los logs. | +| `name` | `String` | Descriptivo, solo para mostrar. | +| `base_url` | `String` | URL del Service Layer, ej. `https://sap10:50000/b1s/v2`. | +| `company_db` | `String` | `CompanyDB` de esa base. | +| `username` / `password` | `String` | Credenciales de login del Service Layer. | +| `active` | `Boolean` | Una base inactiva no se usa como origen ni destino en ningún escenario. | + +Ejemplo (ambiente de desarrollo actual): + +| code | name | base_url | company_db | active | +|---|---|---|---|---| +| `HQ01` | HQ DemoCL | `https://sap10:50000/b1s/v2` | `SBODemoCL` | `true` | +| `SUB_01` | SUB 01 DemoCL | `https://sap10:50000/b1s/v2` | `SUBDemoCL_01` | `true` | +| `SUB_02` | SUB 02 DemoCL | `https://sap10:50000/b1s/v2` | `SUBDemoCL_02` | `true` | +| `SUB_03` | SUB 03 DemoCL | `https://sap10:50000/b1s/v2` | `SUBDemoCL_03` | `true` | +| `SUB_04` | SUB 04 DemoCL | `https://sap10:50000/b1s/v2` | `SUBDemoCL_04` | `true` | + +## `scenarios` + +Una fila por escenario. Ver [`ESCENARIOS.md`](./ESCENARIOS.md) para cómo +armar uno con el CLI. + +| Columna | Tipo | Notas | +|---|---|---| +| `id` | `Int` (PK) | | +| `code` | `String` (único) | Identificador legible del escenario, en mayúsculas (ej. `BP_PROVEEDORES`). Determina el nombre de su archivo (`filter` + `transform`) en minúsculas: `src/scenarios/.ts` (ej. `bp_proveedores.ts`) — ver `scenarioFileSlug` en `src/scenarioTransform.ts`. | +| `name` | `String` | Descriptivo. | +| `business_object` | `String` | Objeto que dispara el escenario: con qué se matchea `webhook_events` y con qué se lee el registro del origen. | +| `destination_business_object` | `String?` | Objeto a usar en el `GET`/`POST`/`PATCH` del **destino**, si es distinto al de origen (ej. origen `PurchaseOrders` → destino `Orders`, típico de comercio intercompañía). `null` ⇒ mismo objeto que `business_object` (el caso común). | +| `origin_database_code` | `String` (FK → `databases.code`) | De dónde se lee el registro completo. | +| `destination_mode` | `ScenarioDestinationMode` | `fixed` (lista fija en `scenario_destinations`) \| `payload` (destino resuelto por `routing_field` — ambos implementados). | +| `key_field` | `String` | Campo clave del registro, ej. `CardCode`, `SalesEmployeeCode`. | +| `key_type` | `ScenarioKeyType` | `string` (va entre comillas en la URL OData) \| `number` (sin comillas — un campo numérico con comillas da HTTP 400). | +| `key_strategy` | `ScenarioKeyStrategy` | `portable` (la clave vale lo mismo en origen y destino) \| `udfLink` (SAP asigna la clave por base; se busca por `match_field`). | +| `match_field` | `String?` | Solo si `key_strategy = udfLink`: nombre del UDF en el destino que guarda la clave de origen (ej. `U_ITC_Source_JdtNum`). | +| `routing_field` | `String?` | Solo si `destination_mode = payload`: UDF en el origen cuyo valor es el `code` de la base destino (ej. `U_ITC_Target`). | +| `active` | `Boolean` | | + +No hay columnas de filtro: el filtro es la función `filter()` del archivo +del escenario (ver [`ARQUITECTURA.md`](./ARQUITECTURA.md)). + +Ejemplo (los 5 escenarios activos hoy en desarrollo): + +| code | business_object | destination_business_object | origin | destinations | key_field | key_strategy | +|---|---|---|---|---|---|---| +| `BP_PROVEEDORES` | `BusinessPartners` | — | `HQ01` | `SUB_01`, `SUB_02` | `CardCode` | `portable` | +| `CHARTOFACCOUNTS` | `ChartOfAccounts` | — | `HQ01` | `SUB_01`, `SUB_02` | `Code` | `portable` | +| `JOURNALENTRIES` | `JournalEntries` | — | `HQ01` | (payload: `U_ITC_Target`) | `JdtNum` | `udfLink` (`U_ITC_Source_JdtNum`) | +| `PROFITCENTERS` | `ProfitCenters` | — | `HQ01` | `SUB_01`, `SUB_02`, `SUB_03` | `CenterCode` | `portable` | +| `SALESPERSONS` | `SalesPersons` | — | `HQ01` | `SUB_01`, `SUB_03` | `SalesEmployeeCode` | `udfLink` (`U_HQ_SlpCode`) | + +Ninguno usa todavía `destination_business_object` (ninguno lo necesita por +ahora) — quedaría, por ejemplo, `PurchaseOrders` → `Orders` en un futuro +escenario de compras intercompañía. + +## `scenario_destinations` + +Destinos fijos de un escenario (solo aplica a `destination_mode = fixed`). +Único por `(scenario_id, database_code)`. + +| Columna | Tipo | Notas | +|---|---|---| +| `scenario_id` | `Int` (FK → `scenarios.id`) | | +| `database_code` | `String` (FK → `databases.code`) | | + +## `scenario_value_mappings` + +Datos puros que el archivo de transformación de un escenario consulta vía +`ctx.mapValue(fieldName, sourceValue)` — ver +[`ARQUITECTURA.md`](./ARQUITECTURA.md). El código decide QUÉ campo mapear y +QUÉ hacer si falta el valor (tirar error, dejar sin traducir, etc.); acá +solo vive la equivalencia. Escopada por escenario (no global por +objeto+campo) porque el mismo campo puede tener dominios de valores +distintos según el escenario (ej. `GroupCode` de clientes vs. de +proveedores). + +| Columna | Tipo | Notas | +|---|---|---| +| `scenario_id` | `Int` (FK) | | +| `field_name` | `String` | Ej. `GroupCode`. | +| `database_code` | `String` (FK → `databases.code`) | El destino al que aplica esta traducción puntual. | +| `source_value` | `String` | Valor tal como viene de la base origen. | +| `target_value` | `String` | Valor a usar en ese destino. | + +Único por `(scenario_id, field_name, database_code, source_value)`. + +Ejemplo real (`BP_PROVEEDORES`, `GroupCode`, hoy 1:1 en ambas subsidiarias): + +| scenario | field_name | database_code | source_value | target_value | +|---|---|---|---|---| +| `BP_PROVEEDORES` | `GroupCode` | `SUB_01` | `101` | `101` | +| `BP_PROVEEDORES` | `GroupCode` | `SUB_01` | `110` | `110` | +| `BP_PROVEEDORES` | `GroupCode` | `SUB_02` | `101` | `101` | +| ... | | | | (7 códigos × 2 destinos = 14 filas) | + +## `replication_attempts` + +Log de auditoría inmutable. Una fila por cada intento de un escenario hacia +una base destino. + +| Columna | Tipo | Notas | +|---|---|---| +| `event_id` | `String` (FK → `webhook_events.id`) | | +| `scenario_id` | `Int` (FK → `scenarios.id`) | Imprescindible porque un evento puede pasar por varios escenarios a la vez. | +| `origin_database` | `String` | Código de la base origen usada en **este** intento — texto libre, no FK: sobrevive si la base se borra o renombra después. | +| `destination_database` | `String` | Ídem para el destino. `'N/A'` si el intento falló antes de resolver un destino (ej. error leyendo el origen). | +| `verb_used` | `String` | `POST` \| `PATCH` \| `SKIPPED` (falta mapeo, o transform tiró error) \| `N/A` (error antes de llamar al destino). | +| `http_status` | `Int?` | | +| `error_message` | `String?` | | +| `payload_sent` | `Json?` | El payload ya transformado enviado al destino — se guarda **siempre**, en éxito y en falla. | +| `result_key` | `String?` | Para `key_strategy=udfLink`: la clave propia del destino (encontrada o recién asignada por SAP en un `POST`). `null` en `key_strategy=portable` y en datos maestros sin esta estrategia. | +| `attempted_at` | `DateTime` | | + +Índices sobre `event_id`, `scenario_id` y `attempted_at`, pensados para la +futura UI de consulta (`sap-intercompany-viewer`, ver `ARQUITECTURA.md`). + +Ejemplo real (`SALESPERSONS`, vendedor nuevo `SalesEmployeeCode=7` en HQ01, +`POST` en ambas subsidiarias — SAP le asignó el mismo número por +coincidencia, pero el motor no lo asume, lo captura del response): + +| event_id | scenario | destination_database | verb_used | http_status | result_key | +|---|---|---|---|---|---| +| `149be29e-...` | `SALESPERSONS` | `SUB_01` | `POST` | `201` | `7` | +| `149be29e-...` | `SALESPERSONS` | `SUB_03` | `POST` | `201` | `7` | diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..85b045d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2968 @@ +{ + "name": "sap-intercompany-worker", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sap-intercompany-worker", + "version": "1.0.0", + "license": "UNLICENSED", + "dependencies": { + "@prisma/adapter-pg": "^7.9.1", + "@prisma/client": "^7.9.1", + "axios": "^1.19.0", + "dotenv": "^17.4.2", + "node-cron": "^4.6.0", + "pg": "^8.23.0", + "prisma": "^7.9.1" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "@types/node-cron": "^3.0.11", + "@types/pg": "^8.23.1", + "tsx": "^4.23.12", + "typescript": "^7.0.2" + } + }, + "node_modules/@electric-sql/pglite": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", + "integrity": "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==", + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.3.tgz", + "integrity": "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ==", + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.4.3" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.3.tgz", + "integrity": "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg==", + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.4.3" + } + }, + "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/@prisma/adapter-pg": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.9.1.tgz", + "integrity": "sha512-Ho2RK1KanQxLNSC0sR5bpiiVep10sWPLXCcxK+KXfI/Q69TMRbiafSvLPv3V9snimX72rMCqGlyJ4sBO4lKTAw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.9.1", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, + "node_modules/@prisma/client": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.9.1.tgz", + "integrity": "sha512-+xgrh2EhJVF79wC0yX5G4PI1Rdcm7Qn/nekNQ+t/O153wtNggruHal+fXHSa0QE+Tp/Cw5wvxeCEhZZ59xGm8Q==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.9.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.9.1.tgz", + "integrity": "sha512-mVIBGYdO5CFmK0HvjxrtfIyQQcPdb88pSCeVQriVQPVZyDovIWblpHfOgcS8QO187j3QF0ePArH8qPhp0AU2vg==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.9.1.tgz", + "integrity": "sha512-4znKhxTmXmuPye9Z6pbIyYb5VZlkZ05qG1L6Dr4g+7oTwc6V50Bs9XirFBDdjWt+H/AabMn9aUnxBcvj8z05aA==", + "license": "Apache-2.0", + "dependencies": { + "c12": "3.3.4", + "deepmerge-ts": "7.1.5", + "effect": "3.20.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.9.1.tgz", + "integrity": "sha512-/cpVZ4itxtcgB8GHBvZtcmuEjq+lWsLrRJxFMbwZrT1RIdtuKmUm7PPGo/wzfbYpBrk+9WmmBE8CHJw2rybKDQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.24.17", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.17.tgz", + "integrity": "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA==", + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.4.3", + "@electric-sql/pglite-socket": "0.1.3", + "@electric-sql/pglite-tools": "0.3.3", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.11", + "find-my-way": "9.7.0", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.4.2", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.9.1.tgz", + "integrity": "sha512-vmHehG7nn/heW32DXXpp13DxxAxVVe6n250oEt3dOL2E/4bt3olktKZN0mzSuxMMronyMSkbeW2uCOn3F4g8RQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.9.1" + } + }, + "node_modules/@prisma/engines": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.9.1.tgz", + "integrity": "sha512-UprXSMNXx2NF5ow4pqaQtE8OuBz6K78B0wc0tn2L28G5r933iWp1DR9Do2qWrsNvvFIP3x6mpEWnQtckMO0Uhg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.9.1", + "@prisma/engines-version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad", + "@prisma/fetch-engine": "7.9.1", + "@prisma/get-platform": "7.9.1" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad.tgz", + "integrity": "sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.9.1.tgz", + "integrity": "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.9.1" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.9.1.tgz", + "integrity": "sha512-9DwxrNTeT25Orbu9CWh0CZvVlyY1lmscpbaeLZcOnuR7zcuFrt91YSmmOfIm7zJ08YOZ6mVzURKwLoMwEBcK8w==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.9.1", + "@prisma/engines-version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad", + "@prisma/get-platform": "7.9.1" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.9.1.tgz", + "integrity": "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.9.1" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/streams-local": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.11.tgz", + "integrity": "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg==", + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "engines": { + "bun": ">=1.2.0", + "node": ">=22.0.0" + } + }, + "node_modules/@prisma/studio-core": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.33.0.tgz", + "integrity": "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg==", + "license": "Apache-2.0", + "dependencies": { + "@radix-ui/react-toggle": "1.1.10", + "@visx/curve": "4.0.1-alpha.0", + "@visx/event": "4.0.1-alpha.0", + "@visx/grid": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/responsive": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", + "d3-array": "3.2.4", + "d3-shape": "3.2.0", + "elkjs": "0.11.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/d3-array": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.0.3.tgz", + "integrity": "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==", + "license": "MIT" + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz", + "integrity": "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", + "integrity": "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.0.tgz", + "integrity": "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-2.1.0.tgz", + "integrity": "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==", + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.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/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "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/@visx/curve": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/curve/-/curve-4.0.1-alpha.0.tgz", + "integrity": "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==", + "license": "MIT", + "dependencies": { + "@visx/vendor": "4.0.0-alpha.0" + } + }, + "node_modules/@visx/event": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/event/-/event-4.0.1-alpha.0.tgz", + "integrity": "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/point": "4.0.1-alpha.0" + } + }, + "node_modules/@visx/grid": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/grid/-/grid-4.0.1-alpha.0.tgz", + "integrity": "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "@visx/curve": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/point": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/group": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/group/-/group-4.0.1-alpha.0.tgz", + "integrity": "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==", + "license": "MIT", + "dependencies": { + "@types/react": "*", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/point": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/point/-/point-4.0.1-alpha.0.tgz", + "integrity": "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==", + "license": "MIT" + }, + "node_modules/@visx/responsive": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/responsive/-/responsive-4.0.1-alpha.0.tgz", + "integrity": "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==", + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.17.13", + "@types/react": "*", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/scale": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/scale/-/scale-4.0.1-alpha.0.tgz", + "integrity": "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==", + "license": "MIT", + "dependencies": { + "@visx/vendor": "4.0.0-alpha.0" + } + }, + "node_modules/@visx/shape": { + "version": "4.0.1-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/shape/-/shape-4.0.1-alpha.0.tgz", + "integrity": "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==", + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.17.13", + "@types/react": "*", + "@visx/curve": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/vendor": "4.0.0-alpha.0", + "classnames": "^2.3.1", + "lodash": "^4.17.21" + }, + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, + "node_modules/@visx/vendor": { + "version": "4.0.0-alpha.0", + "resolved": "https://registry.npmjs.org/@visx/vendor/-/vendor-4.0.0-alpha.0.tgz", + "integrity": "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==", + "license": "MIT and ISC", + "dependencies": { + "@types/d3-array": "3.0.3", + "@types/d3-color": "3.1.0", + "@types/d3-delaunay": "6.0.1", + "@types/d3-format": "3.0.1", + "@types/d3-geo": "3.1.0", + "@types/d3-interpolate": "3.0.1", + "@types/d3-path": "3.1.1", + "@types/d3-scale": "4.0.2", + "@types/d3-shape": "3.1.7", + "@types/d3-time": "3.0.0", + "@types/d3-time-format": "2.1.0", + "d3-array": "3.2.1", + "d3-color": "3.1.0", + "d3-delaunay": "6.0.2", + "d3-format": "3.1.0", + "d3-geo": "3.1.0", + "d3-interpolate": "3.0.1", + "d3-path": "3.1.0", + "d3-scale": "4.0.2", + "d3-shape": "3.2.0", + "d3-time": "3.1.0", + "d3-time-format": "4.1.0", + "internmap": "2.0.3" + } + }, + "node_modules/@visx/vendor/node_modules/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "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/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "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/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "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/better-result": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.10.0.tgz", + "integrity": "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw==", + "license": "MIT" + }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "confbox": "^0.2.4", + "defu": "^6.1.6", + "dotenv": "^17.3.1", + "exsolve": "^1.0.8", + "giget": "^3.2.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "pkg-types": "^2.3.0", + "rc9": "^3.0.1" + }, + "peerDependencies": { + "magicast": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "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/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "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/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.2.tgz", + "integrity": "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "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/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "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/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "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/effect": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/elkjs": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", + "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", + "license": "EPL-2.0" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/find-my-way": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "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/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "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-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "license": "MIT" + }, + "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/giget": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.1.tgz", + "integrity": "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==", + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, + "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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/grammex": { + "version": "3.1.13", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.13.tgz", + "integrity": "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg==", + "license": "MIT" + }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "license": "MIT" + }, + "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/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "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/mysql2": { + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "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/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prisma": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.9.1.tgz", + "integrity": "sha512-aPqePoZIqwlAchbgbFDO/wHqGB+7H1nj9gaM+OsL9h77S5S3TnLd9BgD3LnoeDikULo7cl2HSUrEyQ55Z7DYbg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "7.9.1", + "@prisma/dev": "0.24.17", + "@prisma/engines": "7.9.1", + "@prisma/studio-core": "0.33.0", + "mysql2": "3.15.3", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "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/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, + "node_modules/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "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==", + "devOptional": 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.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/valibot": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2e0796b --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "sap-intercompany-worker", + "version": "1.0.0", + "description": "Replica datos maestros de SAP HeadQuarter hacia subsidiarias", + "type": "module", + "main": "src/index.ts", + "scripts": { + "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": { + "@prisma/adapter-pg": "^7.9.1", + "@prisma/client": "^7.9.1", + "axios": "^1.19.0", + "dotenv": "^17.4.2", + "node-cron": "^4.6.0", + "pg": "^8.23.0", + "prisma": "^7.9.1" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "@types/node-cron": "^3.0.11", + "@types/pg": "^8.23.1", + "tsx": "^4.23.12", + "typescript": "^7.0.2" + } +} diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..831a20f --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +// This file was generated by Prisma, and assumes you have installed the following: +// npm install --save-dev prisma dotenv +import "dotenv/config"; +import { defineConfig } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + }, + datasource: { + url: process.env["DATABASE_URL"], + }, +}); diff --git a/prisma/migrations/20260818223337_init/migration.sql b/prisma/migrations/20260818223337_init/migration.sql new file mode 100644 index 0000000..c5c25ad --- /dev/null +++ b/prisma/migrations/20260818223337_init/migration.sql @@ -0,0 +1,31 @@ +-- CreateEnum +CREATE TYPE "EventStatus" AS ENUM ('pending', 'processing', 'done', 'partial', 'failed'); + +-- CreateTable +CREATE TABLE "webhook_events" ( + "id" TEXT NOT NULL, + "business_object" TEXT NOT NULL, + "operation" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "received_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "status" "EventStatus" NOT NULL DEFAULT 'pending', + + CONSTRAINT "webhook_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "replication_attempts" ( + "id" SERIAL NOT NULL, + "event_id" TEXT NOT NULL, + "subsidiary" TEXT NOT NULL, + "verb_used" TEXT NOT NULL, + "http_status" INTEGER, + "error_message" TEXT, + "attempted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "replication_attempts_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "replication_attempts" ADD CONSTRAINT "replication_attempts_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "webhook_events"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260819231455_add_config_tables/migration.sql b/prisma/migrations/20260819231455_add_config_tables/migration.sql new file mode 100644 index 0000000..abe40f7 --- /dev/null +++ b/prisma/migrations/20260819231455_add_config_tables/migration.sql @@ -0,0 +1,83 @@ +-- CreateEnum +CREATE TYPE "ReplicationRuleType" AS ENUM ('master', 'transaction'); + +-- AlterEnum +ALTER TYPE "EventStatus" ADD VALUE 'filtered'; + +-- AlterTable +ALTER TABLE "replication_attempts" ADD COLUMN "result_key" TEXT; + +-- CreateTable +CREATE TABLE "subsidiaries" ( + "id" SERIAL NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "base_url" TEXT NOT NULL, + "company_db" TEXT NOT NULL, + "username" TEXT NOT NULL, + "password" TEXT NOT NULL, + "routing_value" TEXT, + "active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "subsidiaries_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "replication_rules" ( + "id" SERIAL NOT NULL, + "business_object" TEXT NOT NULL, + "type" "ReplicationRuleType" NOT NULL, + "key_field" TEXT, + "routing_field" TEXT, + "idempotency_field" TEXT, + "active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "replication_rules_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "mapped_fields" ( + "id" SERIAL NOT NULL, + "business_object" TEXT NOT NULL, + "field_name" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "mapped_fields_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "reference_field_mappings" ( + "id" SERIAL NOT NULL, + "business_object" TEXT NOT NULL, + "field_name" TEXT NOT NULL, + "subsidiary_code" TEXT NOT NULL, + "hq_value" TEXT NOT NULL, + "subsidiary_value" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "reference_field_mappings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "subsidiaries_code_key" ON "subsidiaries"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "subsidiaries_routing_value_key" ON "subsidiaries"("routing_value"); + +-- CreateIndex +CREATE UNIQUE INDEX "replication_rules_business_object_key" ON "replication_rules"("business_object"); + +-- CreateIndex +CREATE UNIQUE INDEX "mapped_fields_business_object_field_name_key" ON "mapped_fields"("business_object", "field_name"); + +-- CreateIndex +CREATE UNIQUE INDEX "reference_field_mappings_business_object_field_name_subsidi_key" ON "reference_field_mappings"("business_object", "field_name", "subsidiary_code", "hq_value"); + +-- AddForeignKey +ALTER TABLE "reference_field_mappings" ADD CONSTRAINT "reference_field_mappings_subsidiary_code_fkey" FOREIGN KEY ("subsidiary_code") REFERENCES "subsidiaries"("code") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260820173101_scenario_engine/migration.sql b/prisma/migrations/20260820173101_scenario_engine/migration.sql new file mode 100644 index 0000000..43f2f08 --- /dev/null +++ b/prisma/migrations/20260820173101_scenario_engine/migration.sql @@ -0,0 +1,144 @@ +-- CreateEnum +CREATE TYPE "ScenarioDestinationMode" AS ENUM ('fixed', 'payload'); + +-- DropForeignKey +ALTER TABLE "reference_field_mappings" DROP CONSTRAINT "reference_field_mappings_subsidiary_code_fkey"; + +-- AlterTable +ALTER TABLE "replication_attempts" DROP COLUMN "subsidiary", +ADD COLUMN "destination_database" TEXT NOT NULL, +ADD COLUMN "origin_database" TEXT NOT NULL, +ADD COLUMN "payload_sent" JSONB, +ADD COLUMN "scenario_id" INTEGER NOT NULL; + +-- DropTable +DROP TABLE "mapped_fields"; + +-- DropTable +DROP TABLE "reference_field_mappings"; + +-- DropTable +DROP TABLE "replication_rules"; + +-- DropTable +DROP TABLE "subsidiaries"; + +-- DropEnum +DROP TYPE "ReplicationRuleType"; + +-- CreateTable +CREATE TABLE "databases" ( + "id" SERIAL NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "base_url" TEXT NOT NULL, + "company_db" TEXT NOT NULL, + "username" TEXT NOT NULL, + "password" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "databases_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "scenarios" ( + "id" SERIAL NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "business_object" TEXT NOT NULL, + "origin_database_code" TEXT NOT NULL, + "destination_mode" "ScenarioDestinationMode" NOT NULL, + "key_field" TEXT NOT NULL, + "routing_field" TEXT, + "filter_field" TEXT, + "filter_operator" TEXT, + "filter_value" TEXT, + "active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "scenarios_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "scenario_destinations" ( + "id" SERIAL NOT NULL, + "scenario_id" INTEGER NOT NULL, + "database_code" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "scenario_destinations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "scenario_mapped_fields" ( + "id" SERIAL NOT NULL, + "scenario_id" INTEGER NOT NULL, + "field_name" TEXT NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "scenario_mapped_fields_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "scenario_value_mappings" ( + "id" SERIAL NOT NULL, + "scenario_id" INTEGER NOT NULL, + "field_name" TEXT NOT NULL, + "database_code" TEXT NOT NULL, + "source_value" TEXT NOT NULL, + "target_value" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "scenario_value_mappings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "databases_code_key" ON "databases"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "scenarios_code_key" ON "scenarios"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "scenario_destinations_scenario_id_database_code_key" ON "scenario_destinations"("scenario_id", "database_code"); + +-- CreateIndex +CREATE UNIQUE INDEX "scenario_mapped_fields_scenario_id_field_name_key" ON "scenario_mapped_fields"("scenario_id", "field_name"); + +-- CreateIndex +CREATE UNIQUE INDEX "scenario_value_mappings_scenario_id_field_name_database_cod_key" ON "scenario_value_mappings"("scenario_id", "field_name", "database_code", "source_value"); + +-- CreateIndex +CREATE INDEX "replication_attempts_event_id_idx" ON "replication_attempts"("event_id"); + +-- CreateIndex +CREATE INDEX "replication_attempts_scenario_id_idx" ON "replication_attempts"("scenario_id"); + +-- CreateIndex +CREATE INDEX "replication_attempts_attempted_at_idx" ON "replication_attempts"("attempted_at"); + +-- AddForeignKey +ALTER TABLE "scenarios" ADD CONSTRAINT "scenarios_origin_database_code_fkey" FOREIGN KEY ("origin_database_code") REFERENCES "databases"("code") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "scenario_destinations" ADD CONSTRAINT "scenario_destinations_scenario_id_fkey" FOREIGN KEY ("scenario_id") REFERENCES "scenarios"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "scenario_destinations" ADD CONSTRAINT "scenario_destinations_database_code_fkey" FOREIGN KEY ("database_code") REFERENCES "databases"("code") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "scenario_mapped_fields" ADD CONSTRAINT "scenario_mapped_fields_scenario_id_fkey" FOREIGN KEY ("scenario_id") REFERENCES "scenarios"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "scenario_value_mappings" ADD CONSTRAINT "scenario_value_mappings_scenario_id_fkey" FOREIGN KEY ("scenario_id") REFERENCES "scenarios"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "scenario_value_mappings" ADD CONSTRAINT "scenario_value_mappings_database_code_fkey" FOREIGN KEY ("database_code") REFERENCES "databases"("code") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "replication_attempts" ADD CONSTRAINT "replication_attempts_scenario_id_fkey" FOREIGN KEY ("scenario_id") REFERENCES "scenarios"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/prisma/migrations/20260820190647_scenario_transform_file/migration.sql b/prisma/migrations/20260820190647_scenario_transform_file/migration.sql new file mode 100644 index 0000000..455aa47 --- /dev/null +++ b/prisma/migrations/20260820190647_scenario_transform_file/migration.sql @@ -0,0 +1,15 @@ +-- DropForeignKey +ALTER TABLE "scenario_mapped_fields" DROP CONSTRAINT "scenario_mapped_fields_scenario_id_fkey"; + +-- DropForeignKey +ALTER TABLE "scenario_value_mappings" DROP CONSTRAINT "scenario_value_mappings_database_code_fkey"; + +-- DropForeignKey +ALTER TABLE "scenario_value_mappings" DROP CONSTRAINT "scenario_value_mappings_scenario_id_fkey"; + +-- DropTable +DROP TABLE "scenario_mapped_fields"; + +-- DropTable +DROP TABLE "scenario_value_mappings"; + diff --git a/prisma/migrations/20260820203129_scenario_key_strategy/migration.sql b/prisma/migrations/20260820203129_scenario_key_strategy/migration.sql new file mode 100644 index 0000000..f0f6fc5 --- /dev/null +++ b/prisma/migrations/20260820203129_scenario_key_strategy/migration.sql @@ -0,0 +1,7 @@ +-- CreateEnum +CREATE TYPE "ScenarioKeyStrategy" AS ENUM ('portable', 'udfLink'); + +-- AlterTable +ALTER TABLE "scenarios" ADD COLUMN "key_strategy" "ScenarioKeyStrategy" NOT NULL DEFAULT 'portable', +ADD COLUMN "match_field" TEXT; + diff --git a/prisma/migrations/20260820203620_scenario_key_type/migration.sql b/prisma/migrations/20260820203620_scenario_key_type/migration.sql new file mode 100644 index 0000000..a2abd1f --- /dev/null +++ b/prisma/migrations/20260820203620_scenario_key_type/migration.sql @@ -0,0 +1,6 @@ +-- CreateEnum +CREATE TYPE "ScenarioKeyType" AS ENUM ('string', 'number'); + +-- AlterTable +ALTER TABLE "scenarios" ADD COLUMN "key_type" "ScenarioKeyType" NOT NULL DEFAULT 'string'; + diff --git a/prisma/migrations/20260820221308_scenario_value_mapping_table/migration.sql b/prisma/migrations/20260820221308_scenario_value_mapping_table/migration.sql new file mode 100644 index 0000000..a1358d0 --- /dev/null +++ b/prisma/migrations/20260820221308_scenario_value_mapping_table/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "scenario_value_mappings" ( + "id" SERIAL NOT NULL, + "scenario_id" INTEGER NOT NULL, + "field_name" TEXT NOT NULL, + "database_code" TEXT NOT NULL, + "source_value" TEXT NOT NULL, + "target_value" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "scenario_value_mappings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "scenario_value_mappings_scenario_id_field_name_database_cod_key" ON "scenario_value_mappings"("scenario_id", "field_name", "database_code", "source_value"); + +-- AddForeignKey +ALTER TABLE "scenario_value_mappings" ADD CONSTRAINT "scenario_value_mappings_scenario_id_fkey" FOREIGN KEY ("scenario_id") REFERENCES "scenarios"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "scenario_value_mappings" ADD CONSTRAINT "scenario_value_mappings_database_code_fkey" FOREIGN KEY ("database_code") REFERENCES "databases"("code") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/prisma/migrations/20260820225619_scenario_filter_in_code/migration.sql b/prisma/migrations/20260820225619_scenario_filter_in_code/migration.sql new file mode 100644 index 0000000..7348775 --- /dev/null +++ b/prisma/migrations/20260820225619_scenario_filter_in_code/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "scenarios" DROP COLUMN "filter_field", +DROP COLUMN "filter_operator", +DROP COLUMN "filter_value"; + diff --git a/prisma/migrations/20260821231710_scenario_destination_business_object/migration.sql b/prisma/migrations/20260821231710_scenario_destination_business_object/migration.sql new file mode 100644 index 0000000..b75e9db --- /dev/null +++ b/prisma/migrations/20260821231710_scenario_destination_business_object/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "scenarios" ADD COLUMN "destination_business_object" TEXT; + diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..bd50cc1 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,177 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" +} + +datasource db { + provider = "postgresql" +} + +enum EventStatus { + pending + processing + done + partial + failed + filtered // ningún escenario activo para el objeto aceptó el mensaje: no se reintenta +} + +enum ScenarioDestinationMode { + fixed // datos maestros: la lista de destinos está fija en ScenarioDestination + payload // transacciones: el destino se resuelve leyendo routingField del payload (no implementado aún, ver docs) +} + +// Bandeja de entrada: un registro por evento recibido del webhook de SAP. +// sap-webhook-api inserta acá (además del .json local) apenas recibe la notificación. +model WebhookEvent { + id String @id // el "id" del CloudEvent que manda SAP + businessObject String @map("business_object") // 'BusinessPartners', 'Orders', etc. + operation String // 'Created' | 'Updated' + subject String // CardCode / DocEntry, según el objeto + payload Json + receivedAt DateTime @default(now()) @map("received_at") + status EventStatus @default(pending) + + attempts ReplicationAttempt[] + + @@map("webhook_events") +} + +// Registro único de toda base de datos SAP alcanzable por Service Layer. +// Ya no existe distinción de HQ vs. subsidiaria: cualquier fila puede ser +// origen o destino, según lo que declare cada escenario. +model Database { + id Int @id @default(autoincrement()) + code String @unique // mnemónico corto y estable, ej. 'HQ', 'SUB-NORTE'; asignado una sola vez al crear, NO autoincremental + name String + baseUrl String @map("base_url") + companyDb String @map("company_db") + username String + password String + active Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + originOfScenarios Scenario[] @relation("ScenarioOrigin") + destinations ScenarioDestination[] + valueMappings ScenarioValueMapping[] + + @@map("databases") +} + +enum ScenarioKeyStrategy { + portable // el keyField vale lo mismo en origen y destino (ej. CardCode, ItemCode): GET directo por clave + udfLink // SAP asigna la clave por base (ej. SalesEmployeeCode): se busca por un UDF en el destino que guarda la clave de origen +} + +enum ScenarioKeyType { + string // la clave va entre comillas en la URL OData: BusinessPartners('C23900') + number // la clave va sin comillas: SalesPersons(5) — mandar comillas en un campo numérico da HTTP 400 +} + +// Un escenario es una forma completa e independiente de procesar un business +// object: su propio filtro, base origen, base(s) destino y transformación de +// payload. Puede haber varios escenarios activos para el mismo business +// object a la vez (ej. "clientes" y "proveedores" sobre BusinessPartners), y +// un mismo evento puede matchear y ser procesado por más de uno. +// +// El filtro y la transformación del payload NO viven en esta tabla: cada +// escenario tiene un archivo de código en src/scenarios/.ts con +// SIEMPRE las mismas dos partes — filter(record) -> boolean y +// transform(record, ctx) -> record — porque ambos necesitan lógica +// arbitraria (condiciones compuestas, elegir campos) que una tabla no puede +// expresar. Lo que SÍ vive en tabla son los mapeos de valor puntuales que +// transform consulta vía ctx.mapValue(...) dentro de su lógica — ver +// ScenarioValueMapping más abajo y docs/ESCENARIOS.md — pensado para que a +// futuro una UI permita cargarlos sin editar código, aunque el archivo siga +// siendo quien decide cómo usarlos. +model Scenario { + id Int @id @default(autoincrement()) + code String @unique // 'BP_CLIENTES', 'BP_PROVEEDORES' — también el nombre del archivo en src/scenarios/ + name String + businessObject String @map("business_object") // objeto que dispara el escenario y que se lee del origen (con qué se matchea webhook_events y con qué se hace el GET a la base origen) + destinationBusinessObject String? @map("destination_business_object") // objeto a usar en el GET/POST/PATCH del DESTINO, si es distinto al de origen (ej. origen PurchaseOrders -> destino Orders). null = mismo que businessObject + originDatabaseCode String @map("origin_database_code") + originDatabase Database @relation("ScenarioOrigin", fields: [originDatabaseCode], references: [code]) + destinationMode ScenarioDestinationMode @map("destination_mode") + keyField String @map("key_field") // clave del registro para GET/POST/PATCH, ej. 'CardCode', 'SalesEmployeeCode' + keyType ScenarioKeyType @default(string) @map("key_type") // tipo de dato del keyField en el Service Layer (define si va entre comillas en la URL) + keyStrategy ScenarioKeyStrategy @default(portable) @map("key_strategy") + matchField String? @map("match_field") // solo si keyStrategy = udfLink: UDF en el destino que guarda el valor de keyField de origen, ej. 'U_HQ_SlpCode' + routingField String? @map("routing_field") // solo si destinationMode = payload + active Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + destinations ScenarioDestination[] + valueMappings ScenarioValueMapping[] + attempts ReplicationAttempt[] + + @@map("scenarios") +} + +// Destinos fijos de un escenario en modo 'fixed' (datos maestros). En modo +// 'payload' (transacciones) no se usa: el destino se resuelve en runtime. +model ScenarioDestination { + id Int @id @default(autoincrement()) + scenarioId Int @map("scenario_id") + scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) + databaseCode String @map("database_code") + database Database @relation(fields: [databaseCode], references: [code]) + createdAt DateTime @default(now()) @map("created_at") + + @@unique([scenarioId, databaseCode]) + @@map("scenario_destinations") +} + +// Tabla de equivalencias que el archivo de transformación del escenario +// consulta vía ctx.mapValue(fieldName, sourceValue) — el código decide QUÉ +// campo mapear y QUÉ hacer si falta (fallar, dejar sin traducir, etc.); acá +// solo vive el dato. Escopada por escenario, no global por objeto+campo, +// porque el mismo campo puede tener dominios de valores distintos según el +// escenario (ej. GroupCode de clientes vs. de proveedores). Pensada para +// que a futuro una UI la administre sin tocar código. +model ScenarioValueMapping { + id Int @id @default(autoincrement()) + scenarioId Int @map("scenario_id") + scenario Scenario @relation(fields: [scenarioId], references: [id], onDelete: Cascade) + fieldName String @map("field_name") + databaseCode String @map("database_code") + database Database @relation(fields: [databaseCode], references: [code]) + sourceValue String @map("source_value") + targetValue String @map("target_value") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@unique([scenarioId, fieldName, databaseCode, sourceValue]) + @@map("scenario_value_mappings") +} + +// Un registro por cada intento de réplica de un evento, dentro de UN +// escenario puntual, hacia UNA base destino. origin_database y +// destination_database son texto libre (sin FK) a propósito: es un log de +// auditoría inmutable que no debe verse afectado si la base o el escenario +// se borran o renombran después. +model ReplicationAttempt { + id Int @id @default(autoincrement()) + event WebhookEvent @relation(fields: [eventId], references: [id]) + eventId String @map("event_id") + scenario Scenario @relation(fields: [scenarioId], references: [id]) + scenarioId Int @map("scenario_id") + originDatabase String @map("origin_database") + destinationDatabase String @map("destination_database") // 'N/A' si falló antes de resolver destino (ej. error leyendo el origen) + verbUsed String @map("verb_used") // 'POST' | 'PATCH' | 'SKIPPED' | 'N/A' + httpStatus Int? @map("http_status") + errorMessage String? @map("error_message") + payloadSent Json? @map("payload_sent") // payload ya transformado enviado al destino; se guarda en éxito Y en falla + resultKey String? @map("result_key") // clave propia del destino cuando difiere de la de origen (keyStrategy=udfLink) o DocEntry (transacciones); null si no aplica + attemptedAt DateTime @default(now()) @map("attempted_at") + + @@index([eventId]) + @@index([scenarioId]) + @@index([attemptedAt]) + @@map("replication_attempts") +} diff --git a/scripts/manage-config.ts b/scripts/manage-config.ts new file mode 100644 index 0000000..949f88f --- /dev/null +++ b/scripts/manage-config.ts @@ -0,0 +1,315 @@ +// CLI de administración de la configuración de replicación (bases de datos, +// escenarios, destinos y mapeos de valor). Reemplaza la edición manual de +// SUBSIDIARIES/HQ_* en .env: los cambios acá tienen efecto en el próximo +// tick del worker, sin reiniciar el proceso. +// +// El filtro y la transformación del payload de un escenario son un archivo +// de código en src/scenarios/.ts (ver docs/ESCENARIOS.md) — SIEMPRE +// con las mismas dos partes (filter + transform). `scenario:add` genera uno +// de partida la primera vez que se crea un escenario nuevo. Los mapeos de +// valor (scenario:mapping:*) SÍ se administran acá: son datos que ese +// archivo consulta vía ctx.mapValue(...) dentro de transform, pero la +// decisión de qué campo mapear y qué hacer si falta el mapeo queda siempre +// en el código. +// +// Uso: +// tsx scripts/manage-config.ts database:add +// tsx scripts/manage-config.ts database:disable +// tsx scripts/manage-config.ts database:list +// +// tsx scripts/manage-config.ts scenario:add +// [--key-strategy udfLink --match-field U_HQ_SlpCode] [--key-type number] +// [--destination-mode payload --routing-field U_ITC_Target] +// tsx scripts/manage-config.ts scenario:rename +// tsx scripts/manage-config.ts scenario:disable +// tsx scripts/manage-config.ts scenario:list +// +// tsx scripts/manage-config.ts scenario:add-destination +// tsx scripts/manage-config.ts scenario:remove-destination +// +// tsx scripts/manage-config.ts scenario:mapping:add +// tsx scripts/manage-config.ts scenario:mapping:list + +import 'dotenv/config'; +import { existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '../src/generated/prisma/client.ts'; +import { scenarioFileSlug } from '../src/scenarioTransform.ts'; + +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); +const prisma = new PrismaClient({ adapter }); + +const scenariosDir = fileURLToPath(new URL('../src/scenarios/', import.meta.url)); + +function scenarioFilePath(code: string): string { + return `${scenariosDir}${scenarioFileSlug(code)}.ts`; +} + +function scaffoldTransformFile(code: string): string | null { + const path = scenarioFilePath(code); + if (existsSync(path)) return null; + mkdirSync(scenariosDir, { recursive: true }); + writeFileSync( + path, + `// Escenario ${code} — completar según corresponda (ver docs/ESCENARIOS.md). +import type { ScenarioFilterContext, ScenarioTransformContext } from '../scenarioTransform.ts'; + +// --- Filtro: decide si este escenario aplica al registro --- +// Puede ser async: ctx.get(path) permite consultar la base ORIGEN (ej. si +// el filtro depende de un dato que no viene en el payload del evento). +export function filter(record: Record, ctx: ScenarioFilterContext): boolean | Promise { + return true; // reemplazar con la condición real, o dejar así para "sin filtro" +} + +// --- Transformación: arma el payload para un destino puntual --- +// Se llama una vez por destino: ctx.destinationCode permite transformar +// distinto según a cuál le toque. Puede lanzar una excepción para marcar +// el intento como SKIPPED (ej. un valor sin mapeo cargado). +export function transform(record: Record, ctx: ScenarioTransformContext): Record { + // --- Mapeo: ctx.mapValue(fieldName, sourceValue) consulta scenario_value_mappings + // (cargados con 'scenario:mapping:add') para ESTE escenario y destino --- + + return { ...record }; +} +`, + ); + return path; +} + +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 requireScenario(code: string) { + const scenario = await prisma.scenario.findUnique({ where: { code } }); + if (!scenario) fail(`no existe el escenario '${code}'`); + return scenario; +} + +async function main() { + const [command, ...args] = process.argv.slice(2); + + switch (command) { + case 'database:add': { + const [code, name, baseUrl, companyDb, username, password] = args; + if (!code || !name || !baseUrl || !companyDb || !username || !password) { + fail('database:add '); + } + const db = await prisma.database.upsert({ + where: { code }, + create: { code, name, baseUrl, companyDb, username, password }, + update: { name, baseUrl, companyDb, username, password, active: true }, + }); + console.log(`Base de datos ${db.code} guardada (id=${db.id}).`); + break; + } + + case 'database:disable': { + const [code] = args; + if (!code) fail('database:disable '); + await prisma.database.update({ where: { code }, data: { active: false } }); + console.log(`Base de datos ${code} desactivada.`); + break; + } + + case 'database:list': { + const dbs = await prisma.database.findMany({ orderBy: { code: 'asc' } }); + console.table(dbs.map((d) => ({ code: d.code, name: d.name, baseUrl: d.baseUrl, companyDb: d.companyDb, active: d.active }))); + break; + } + + case 'scenario:add': { + const [code, name, businessObject, originDatabaseCode, keyField] = args; + if (!code || !name || !businessObject || !originDatabaseCode || !keyField) { + fail( + 'scenario:add ' + + '[--key-strategy udfLink --match-field U_HQ_SlpCode] [--key-type number] ' + + '[--destination-mode payload --routing-field U_ITC_Target] ' + + '[--destination-object Orders]', + ); + } + + const keyStrategyFlag = flag(args, 'key-strategy') ?? 'portable'; + if (keyStrategyFlag !== 'portable' && keyStrategyFlag !== 'udfLink') { + fail("--key-strategy debe ser 'portable' o 'udfLink'"); + } + const matchField = flag(args, 'match-field') ?? null; + if (keyStrategyFlag === 'udfLink' && !matchField) { + fail('--key-strategy udfLink necesita --match-field '); + } + const keyTypeFlag = flag(args, 'key-type') ?? 'string'; + if (keyTypeFlag !== 'string' && keyTypeFlag !== 'number') { + fail("--key-type debe ser 'string' o 'number' (numérico va sin comillas en la URL OData, ej. SalesEmployeeCode)"); + } + const destinationModeFlag = flag(args, 'destination-mode') ?? 'fixed'; + if (destinationModeFlag !== 'fixed' && destinationModeFlag !== 'payload') { + fail("--destination-mode debe ser 'fixed' o 'payload'"); + } + const routingField = flag(args, 'routing-field') ?? null; + if (destinationModeFlag === 'payload' && !routingField) { + fail('--destination-mode payload necesita --routing-field (campo en el origen con el code de la base destino)'); + } + const destinationObject = flag(args, 'destination-object') ?? null; + + const existed = (await prisma.scenario.findUnique({ where: { code } })) !== null; + + const scenario = await prisma.scenario.upsert({ + where: { code }, + create: { + code, + name, + businessObject, + destinationBusinessObject: destinationObject, + originDatabaseCode, + destinationMode: destinationModeFlag, + routingField, + keyField, + keyType: keyTypeFlag, + keyStrategy: keyStrategyFlag, + matchField, + }, + update: { + name, + businessObject, + destinationBusinessObject: destinationObject, + originDatabaseCode, + destinationMode: destinationModeFlag, + routingField, + keyField, + keyType: keyTypeFlag, + keyStrategy: keyStrategyFlag, + matchField, + active: true, + }, + }); + console.log(`Escenario ${scenario.code} guardado (id=${scenario.id}).`); + + if (!existed) { + const scaffolded = scaffoldTransformFile(scenario.code); + if (scaffolded) console.log(`Archivo de transformación creado: ${scaffolded} (editalo antes de usar el escenario).`); + } + break; + } + + case 'scenario:rename': { + const [oldCode, newCode] = args; + if (!oldCode || !newCode) fail('scenario:rename '); + const scenario = await requireScenario(oldCode); + const oldPath = scenarioFilePath(oldCode); + const newPath = scenarioFilePath(newCode); + if (existsSync(newPath)) fail(`ya existe un archivo en ${newPath}`); + + await prisma.scenario.update({ where: { id: scenario.id }, data: { code: newCode } }); + if (existsSync(oldPath)) { + renameSync(oldPath, newPath); + console.log(`Archivo renombrado: ${oldPath} -> ${newPath}`); + } else { + console.warn(`Aviso: no se encontró ${oldPath} — creá ${newPath} a mano antes de usar el escenario.`); + } + console.log(`Escenario ${oldCode} renombrado a ${newCode}.`); + break; + } + + case 'scenario:disable': { + const [code] = args; + if (!code) fail('scenario:disable '); + await prisma.scenario.update({ where: { code }, data: { active: false } }); + console.log(`Escenario ${code} desactivado.`); + break; + } + + case 'scenario:list': { + const scenarios = await prisma.scenario.findMany({ + orderBy: { code: 'asc' }, + include: { destinations: true }, + }); + console.table( + scenarios.map((s) => ({ + code: s.code, + businessObject: s.destinationBusinessObject ? `${s.businessObject} -> ${s.destinationBusinessObject}` : s.businessObject, + origin: s.originDatabaseCode, + destinations: + s.destinationMode === 'payload' ? `(via payload: ${s.routingField})` : s.destinations.map((d) => d.databaseCode).join(', '), + keyField: s.keyStrategy === 'udfLink' ? `${s.keyField} (via ${s.matchField})` : s.keyField, + archivo: `src/scenarios/${scenarioFileSlug(s.code)}.ts`, + active: s.active, + })), + ); + break; + } + + case 'scenario:add-destination': { + const [scenarioCode, databaseCode] = args; + if (!scenarioCode || !databaseCode) fail('scenario:add-destination '); + const scenario = await requireScenario(scenarioCode); + await prisma.scenarioDestination.upsert({ + where: { scenarioId_databaseCode: { scenarioId: scenario.id, databaseCode } }, + create: { scenarioId: scenario.id, databaseCode }, + update: {}, + }); + console.log(`Destino ${databaseCode} agregado a escenario ${scenarioCode}.`); + break; + } + + case 'scenario:remove-destination': { + const [scenarioCode, databaseCode] = args; + if (!scenarioCode || !databaseCode) fail('scenario:remove-destination '); + const scenario = await requireScenario(scenarioCode); + await prisma.scenarioDestination.delete({ + where: { scenarioId_databaseCode: { scenarioId: scenario.id, databaseCode } }, + }); + console.log(`Destino ${databaseCode} quitado de escenario ${scenarioCode}.`); + break; + } + + case 'scenario:mapping:add': { + const [scenarioCode, fieldName, databaseCode, sourceValue, targetValue] = args; + if (!scenarioCode || !fieldName || !databaseCode || !sourceValue || !targetValue) { + fail('scenario:mapping:add '); + } + const scenario = await requireScenario(scenarioCode); + await prisma.scenarioValueMapping.upsert({ + where: { + scenarioId_fieldName_databaseCode_sourceValue: { scenarioId: scenario.id, fieldName, databaseCode, sourceValue }, + }, + create: { scenarioId: scenario.id, fieldName, databaseCode, sourceValue, targetValue }, + update: { targetValue }, + }); + console.log(`Mapeo ${scenarioCode}/${fieldName} [${databaseCode}]: ${sourceValue} -> ${targetValue} guardado.`); + break; + } + + case 'scenario:mapping:list': { + const [scenarioCode, fieldName] = args; + if (!scenarioCode || !fieldName) fail('scenario:mapping:list '); + const scenario = await requireScenario(scenarioCode); + const mappings = await prisma.scenarioValueMapping.findMany({ + where: { scenarioId: scenario.id, fieldName }, + orderBy: [{ databaseCode: 'asc' }, { sourceValue: 'asc' }], + }); + console.table(mappings.map((m) => ({ database: m.databaseCode, sourceValue: m.sourceValue, targetValue: m.targetValue }))); + break; + } + + default: + fail( + `comando desconocido: ${command ?? '(ninguno)'}\nComandos disponibles: database:add, database:disable, database:list, ` + + 'scenario:add, scenario:rename, scenario:disable, scenario:list, scenario:add-destination, scenario:remove-destination, ' + + 'scenario:mapping:add, scenario:mapping:list', + ); + } +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..08c1f3f --- /dev/null +++ b/src/config.ts @@ -0,0 +1,16 @@ +import 'dotenv/config'; + +export interface ServiceLayerTarget { + name: string; + baseUrl: string; + companyDb: string; + username: string; + password: string; +} + +// Ya no hay una base "HQ" hardcodeada en .env: toda base (incluida la casa +// matriz) es una fila más en la tabla `databases`, y cada escenario declara +// cuál usa como origen. Ver src/replicationConfig.ts y scripts/manage-config.ts. +export const config = { + pollIntervalSeconds: Number(process.env.POLL_INTERVAL_SECONDS ?? 30), +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..89355d7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,33 @@ +import cron from 'node-cron'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from './generated/prisma/client.ts'; +import { config } from './config.ts'; +import { loadReplicationConfig } from './replicationConfig.ts'; +import { processPendingEvents } from './replicateEvents.ts'; + +const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); +const prisma = new PrismaClient({ adapter }); + +async function runOnce(): Promise { + try { + const cfg = await loadReplicationConfig(prisma); + await processPendingEvents(prisma, cfg); + } catch (err) { + console.error('[worker] Error procesando eventos pendientes:', err); + } +} + +const runOnceOnly = process.argv.includes('--once'); + +if (runOnceOnly) { + await runOnce(); + await prisma.$disconnect(); + process.exit(0); +} else { + const cronExpression = `*/${config.pollIntervalSeconds} * * * * *`; + console.log( + `[worker] sap-intercompany-worker arrancó. Revisando eventos pendientes cada ${config.pollIntervalSeconds}s.`, + ); + cron.schedule(cronExpression, runOnce); + await runOnce(); // primera corrida inmediata, sin esperar el primer tick +} diff --git a/src/replicateEvents.ts b/src/replicateEvents.ts new file mode 100644 index 0000000..f0ab90b --- /dev/null +++ b/src/replicateEvents.ts @@ -0,0 +1,418 @@ +import type { PrismaClient } from './generated/prisma/client.ts'; +import { Prisma } from './generated/prisma/client.ts'; +import { ServiceLayerClient } from './serviceLayer.ts'; +import type { ServiceLayerTarget } from './config.ts'; +import { + applyScenarioTransform, + buildScenarioFilterContext, + buildScenarioTransformContext, + loadScenarioModule, +} from './scenarioTransform.ts'; +import type { ReplicationConfig } from './replicationConfig.ts'; + +// Campos que no se deben reenviar tal cual al hacer PATCH/POST en el +// destino (metadata propia de la lectura, no del dato en sí). +const FIELDS_TO_STRIP = ['@odata.context', '@odata.etag']; + +type Database = ReplicationConfig['databases'][number]; +type ScenarioRow = ReplicationConfig['scenarios'][number]; + +function cleanRecord(record: Record): Record { + const clean = { ...record }; + for (const field of FIELDS_TO_STRIP) delete clean[field]; + return clean; +} + +function toServiceLayerTarget(db: Database): ServiceLayerTarget { + return { name: db.code, baseUrl: db.baseUrl, companyDb: db.companyDb, username: db.username, password: db.password }; +} + +function escapeODataString(value: string): string { + return value.replace(/'/g, "''"); +} + +// El CloudEvent trae "source": "/default/sap.b1/" — de qué base +// SAP salió la notificación. Un escenario solo debe reaccionar a eventos que +// realmente vinieron de SU base origen configurada: si una base DESTINO +// también tiene su propia suscripción de webhook activa (para otro fin), un +// PATCH nuestro ahí dispara una notificación que, sin este chequeo, el +// motor volvería a tomar como si fuera un cambio nuevo en el origen — +// reprocesándolo, volviendo a hacer PATCH, y así indefinidamente. +function extractSourceCompanyDb(payload: unknown): string | undefined { + if (!payload || typeof payload !== 'object') return undefined; + const source = (payload as Record).source; + if (typeof source !== 'string') return undefined; + const segments = source.split('/').filter(Boolean); + return segments.length > 0 ? segments[segments.length - 1] : undefined; +} + +// Un campo numérico va sin comillas en la URL OData (SalesPersons(5)); uno de +// texto va entre comillas (BusinessPartners('C23900')). Mandar comillas en un +// campo numérico devuelve HTTP 400. +function formatKeySegment(key: string, keyType: ScenarioRow['keyType']): string { + return keyType === 'number' ? key : `'${escapeODataString(key)}'`; +} + +type ExistenceResult = { found: true; destinationKey: string } | { found: false } | { error: string }; + +// Resuelve si el registro ya existe en el destino, y con qué clave. +// - keyStrategy='portable': la clave vale lo mismo en origen y destino (CardCode, ItemCode) -> GET directo. +// - keyStrategy='udfLink': SAP asigna la clave por base (ej. SlpCode) -> se busca por el UDF (matchField) +// que guarda el valor de la clave de origen, y la clave real del destino sale del registro encontrado. +async function resolveDestinationRecord( + client: ServiceLayerClient, + destinationBusinessObject: string, + scenario: ScenarioRow, + originKey: string, +): Promise { + if (scenario.keyStrategy === 'portable') { + const res = await client.get(`/${destinationBusinessObject}(${formatKeySegment(originKey, scenario.keyType)})`); + if (res.status === 200) return { found: true, destinationKey: originKey }; + if (res.status === 404) return { found: false }; + return { error: `Respuesta inesperada al chequear existencia: HTTP ${res.status}` }; + } + + if (!scenario.matchField) { + return { error: `Escenario '${scenario.code}' es keyStrategy=udfLink pero no tiene matchField configurado` }; + } + const filter = `${scenario.matchField} eq '${escapeODataString(originKey)}'`; + const res = await client.get(`/${destinationBusinessObject}?$filter=${encodeURIComponent(filter)}`); + if (res.status !== 200) { + return { error: `Respuesta inesperada al buscar por ${scenario.matchField}: HTTP ${res.status}` }; + } + const matches: Array> = Array.isArray(res.data?.value) ? res.data.value : []; + if (matches.length === 0) return { found: false }; + + const destinationKey = matches[0][scenario.keyField]; + if (destinationKey === undefined || destinationKey === null) { + return { error: `Se encontró un registro por ${scenario.matchField} pero no tiene el campo clave '${scenario.keyField}'` }; + } + return { found: true, destinationKey: String(destinationKey) }; +} + +// Arma el payload para UN destino puntual, resuelve si existe (PATCH) o no +// (POST), y registra el intento. Común a destinationMode='fixed' (destino +// de una lista fija) y 'payload' (destino resuelto desde el propio mensaje) +// — a esta altura ya no importa de dónde salió destCode. +async function replicateToDestination( + prisma: PrismaClient, + event: { id: string }, + scenario: ScenarioRow, + destinationBusinessObject: string, + key: string, + originDb: Database, + sourceRecord: Record, + scenarioModule: Awaited>, + cfg: ReplicationConfig, + destCode: string, +): Promise { + const destDb = cfg.databases.find((d) => d.code === destCode); + + if (!destDb) { + await prisma.replicationAttempt.create({ + data: { + eventId: event.id, + scenarioId: scenario.id, + originDatabase: originDb.code, + destinationDatabase: destCode, + verbUsed: 'N/A', + httpStatus: null, + errorMessage: `Base destino '${destCode}' no existe o está inactiva`, + }, + }); + return false; + } + + let transformedRecord: Record; + try { + const ctx = buildScenarioTransformContext(destDb.code, scenario.id, cfg.valueMappings); + transformedRecord = applyScenarioTransform(scenarioModule.transform, sourceRecord, ctx); + } catch (err) { + await prisma.replicationAttempt.create({ + data: { + eventId: event.id, + scenarioId: scenario.id, + originDatabase: originDb.code, + destinationDatabase: destDb.code, + verbUsed: 'SKIPPED', + httpStatus: null, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + return false; + } + + const client = new ServiceLayerClient(toServiceLayerTarget(destDb)); + + // En keyStrategy=udfLink, el UDF con la clave de origen viaja SIEMPRE en el + // payload (tanto al crear como al actualizar) para que la relación quede + // registrada en SAP mismo, no solo en nuestra config. + const payloadToSend = + scenario.keyStrategy === 'udfLink' && scenario.matchField + ? { ...transformedRecord, [scenario.matchField]: key } + : transformedRecord; + + let verbUsed: 'POST' | 'PATCH' = 'POST'; + let httpStatus: number | null = null; + let errorMessage: string | null = null; + let resultKey: string | null = null; + + try { + const existence = await resolveDestinationRecord(client, destinationBusinessObject, scenario, key); + if ('error' in existence) { + errorMessage = existence.error; + } else if (existence.found) { + verbUsed = 'PATCH'; + resultKey = scenario.keyStrategy === 'udfLink' ? existence.destinationKey : null; + const res = await client.patch( + `/${destinationBusinessObject}(${formatKeySegment(existence.destinationKey, scenario.keyType)})`, + payloadToSend, + ); + httpStatus = res.status; + if (res.status >= 400) errorMessage = JSON.stringify(res.data); + } else { + verbUsed = 'POST'; + const res = await client.post(`/${destinationBusinessObject}`, payloadToSend); + httpStatus = res.status; + if (res.status >= 400) { + errorMessage = JSON.stringify(res.data); + } else if (scenario.keyStrategy === 'udfLink') { + const createdKey = (res.data as Record | undefined)?.[scenario.keyField]; + resultKey = createdKey !== undefined && createdKey !== null ? String(createdKey) : null; + } + } + } catch (err) { + errorMessage = err instanceof Error ? err.message : String(err); + } + + const success = httpStatus !== null && httpStatus >= 200 && httpStatus < 300; + + await prisma.replicationAttempt.create({ + data: { + eventId: event.id, + scenarioId: scenario.id, + originDatabase: originDb.code, + destinationDatabase: destDb.code, + verbUsed, + httpStatus, + errorMessage, + payloadSent: payloadToSend as Prisma.InputJsonValue, + resultKey, + }, + }); + + return success; +} + +export async function processPendingEvents(prisma: PrismaClient, cfg: ReplicationConfig): Promise { + const businessObjects = [...new Set(cfg.scenarios.map((s) => s.businessObject))]; + + for (const businessObject of businessObjects) { + const events = await prisma.webhookEvent.findMany({ + where: { businessObject, status: 'pending' }, + orderBy: { receivedAt: 'asc' }, + }); + if (events.length === 0) continue; + + const scenarios = cfg.scenarios.filter((s) => s.businessObject === businessObject); + console.log(`[replicate] ${events.length} evento(s) de ${businessObject} pendientes, ${scenarios.length} escenario(s) activo(s)`); + + for (const event of events) { + await prisma.webhookEvent.update({ where: { id: event.id }, data: { status: 'processing' } }); + const key = event.subject; + const sourceCompanyDb = extractSourceCompanyDb(event.payload); + + let matchedAnyScenario = false; + let totalAttempts = 0; + let successAttempts = 0; + + for (const scenario of scenarios) { + const originDb = cfg.databases.find((d) => d.code === scenario.originDatabaseCode); + if (originDb && sourceCompanyDb && sourceCompanyDb !== originDb.companyDb) { + console.log( + `[${event.id}] escenario '${scenario.code}' no aplica: el evento vino de '${sourceCompanyDb}', no de la base origen configurada '${originDb.companyDb}' (${originDb.code})`, + ); + continue; + } + if (!originDb) { + console.error(`[${event.id}] escenario '${scenario.code}': base origen '${scenario.originDatabaseCode}' no existe o está inactiva`); + matchedAnyScenario = true; + totalAttempts++; + await prisma.replicationAttempt.create({ + data: { + eventId: event.id, + scenarioId: scenario.id, + originDatabase: scenario.originDatabaseCode, + destinationDatabase: 'N/A', + verbUsed: 'N/A', + httpStatus: null, + errorMessage: `Base origen '${scenario.originDatabaseCode}' no existe o está inactiva`, + }, + }); + continue; + } + + const origin = new ServiceLayerClient(toServiceLayerTarget(originDb)); + let sourceRecord: Record; + try { + const res = await origin.get(`/${businessObject}(${formatKeySegment(key, scenario.keyType)})`); + if (res.status !== 200) { + throw new Error(`HTTP ${res.status} leyendo ${key} de ${originDb.code}`); + } + sourceRecord = cleanRecord(res.data); + } catch (err) { + console.error(`[${event.id}] escenario '${scenario.code}': error leyendo ${key} de ${originDb.code}:`, err); + matchedAnyScenario = true; + totalAttempts++; + await prisma.replicationAttempt.create({ + data: { + eventId: event.id, + scenarioId: scenario.id, + originDatabase: originDb.code, + destinationDatabase: 'N/A', + verbUsed: 'N/A', + httpStatus: null, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + continue; + } + + let scenarioModule: Awaited>; + try { + scenarioModule = await loadScenarioModule(scenario.code); + } catch (err) { + console.error(`[${event.id}] escenario '${scenario.code}': ${err instanceof Error ? err.message : String(err)}`); + matchedAnyScenario = true; + totalAttempts++; + await prisma.replicationAttempt.create({ + data: { + eventId: event.id, + scenarioId: scenario.id, + originDatabase: originDb.code, + destinationDatabase: 'N/A', + verbUsed: 'N/A', + httpStatus: null, + errorMessage: err instanceof Error ? err.message : String(err), + }, + }); + continue; + } + + let filterPasses: boolean; + try { + const filterCtx = buildScenarioFilterContext(originDb.code, (path) => origin.get(path)); + filterPasses = await scenarioModule.filter(sourceRecord, filterCtx); + } catch (err) { + console.error(`[${event.id}] escenario '${scenario.code}': error en filter():`, err); + matchedAnyScenario = true; + totalAttempts++; + await prisma.replicationAttempt.create({ + data: { + eventId: event.id, + scenarioId: scenario.id, + originDatabase: originDb.code, + destinationDatabase: 'N/A', + verbUsed: 'N/A', + httpStatus: null, + errorMessage: `Error en filter(): ${err instanceof Error ? err.message : String(err)}`, + }, + }); + continue; + } + + if (!filterPasses) { + console.log(`[${event.id}] escenario '${scenario.code}' no aplica: filter() devolvió false`); + continue; + } + matchedAnyScenario = true; + + // Por default el destino usa el mismo objeto que el origen — pero un + // escenario puede pedir uno distinto (ej. origen PurchaseOrders -> + // destino Orders, típico de comercio intercompañía). + const destinationBusinessObject = scenario.destinationBusinessObject ?? businessObject; + + if (scenario.destinationMode === 'fixed') { + const destinations = cfg.destinations.filter((d) => d.scenarioId === scenario.id); + for (const dest of destinations) { + totalAttempts++; + const ok = await replicateToDestination( + prisma, + event, + scenario, + destinationBusinessObject, + key, + originDb, + sourceRecord, + scenarioModule, + cfg, + dest.databaseCode, + ); + if (ok) successAttempts++; + } + } else { + // destinationMode='payload': el destino no está en config, se + // resuelve leyendo routingField del registro recién leído del origen. + if (!scenario.routingField) { + totalAttempts++; + await prisma.replicationAttempt.create({ + data: { + eventId: event.id, + scenarioId: scenario.id, + originDatabase: originDb.code, + destinationDatabase: 'N/A', + verbUsed: 'N/A', + httpStatus: null, + errorMessage: `Escenario '${scenario.code}' es destinationMode=payload pero no tiene routingField configurado`, + }, + }); + } else { + const rawTarget = sourceRecord[scenario.routingField]; + const targetCode = typeof rawTarget === 'string' ? rawTarget.trim() : rawTarget != null ? String(rawTarget) : ''; + totalAttempts++; + if (!targetCode) { + await prisma.replicationAttempt.create({ + data: { + eventId: event.id, + scenarioId: scenario.id, + originDatabase: originDb.code, + destinationDatabase: 'N/A', + verbUsed: 'N/A', + httpStatus: null, + errorMessage: `El campo de ruteo '${scenario.routingField}' viene vacío en el registro de origen`, + }, + }); + } else { + const ok = await replicateToDestination( + prisma, + event, + scenario, + destinationBusinessObject, + key, + originDb, + sourceRecord, + scenarioModule, + cfg, + targetCode, + ); + if (ok) successAttempts++; + } + } + } + } + + const finalStatus = !matchedAnyScenario + ? 'filtered' + : totalAttempts === 0 + ? 'failed' + : successAttempts === totalAttempts + ? 'done' + : successAttempts === 0 + ? 'failed' + : 'partial'; + + await prisma.webhookEvent.update({ where: { id: event.id }, data: { status: finalStatus } }); + console.log(`[${event.id}] ${key}: ${successAttempts}/${totalAttempts} intentos OK -> ${finalStatus}`); + } + } +} diff --git a/src/replicationConfig.ts b/src/replicationConfig.ts new file mode 100644 index 0000000..5ed7598 --- /dev/null +++ b/src/replicationConfig.ts @@ -0,0 +1,24 @@ +import type { PrismaClient } from './generated/prisma/client.ts'; + +// Config leída de Postgres al principio de cada tick del cron (no hace falta +// TTL cache aparte: el propio POLL_INTERVAL_SECONDS ya es el punto de refresco). +// La transformación de payload NO está acá: vive en código, en +// src/scenarios/.ts (ver src/scenarioTransform.ts). valueMappings sí +// es dato — ese código la consulta vía ctx.mapValue(...). +export interface ReplicationConfig { + databases: Awaited>; + scenarios: Awaited>; + destinations: Awaited>; + valueMappings: Awaited>; +} + +export async function loadReplicationConfig(prisma: PrismaClient): Promise { + const [databases, scenarios, destinations, valueMappings] = await Promise.all([ + prisma.database.findMany({ where: { active: true } }), + prisma.scenario.findMany({ where: { active: true } }), + prisma.scenarioDestination.findMany(), + prisma.scenarioValueMapping.findMany(), + ]); + + return { databases, scenarios, destinations, valueMappings }; +} diff --git a/src/scenarioTransform.ts b/src/scenarioTransform.ts new file mode 100644 index 0000000..1527649 --- /dev/null +++ b/src/scenarioTransform.ts @@ -0,0 +1,114 @@ +// Cada escenario tiene un archivo en src/scenarios/ con SIEMPRE las mismas +// dos partes. El nombre del archivo se deriva del `code` de la fila en +// `scenarios` pasándolo a minúsculas (scenarioFileSlug) — el code en sí +// queda en mayúsculas en la DB/CLI/logs (es el identificador legible), pero +// como nombre de archivo TS en mayúsculas no es una convención usual: +// +// export function filter(record: Record, ctx: ScenarioFilterContext): boolean | Promise +// export function transform(record: Record, ctx: ScenarioTransformContext): Record +// +// `filter` decide si el escenario aplica a ESE registro puntual (true = +// aplica). Reemplaza lo que antes era un filtro declarativo en la tabla +// scenarios (un solo campo/operador/valor) — como código, admite cualquier +// condición (AND/OR, varios campos, lo que haga falta), incluida una +// llamada extra al Service Layer vía ctx.get(...) cuando el dato para +// filtrar no viene en el payload del evento (típico en transacciones: una +// Factura trae el CardCode del cliente, pero el filtro depende de un campo +// del BusinessPartner, no de la Factura misma). +// +// `transform` arma el payload a enviar a un destino puntual — se llama una +// vez por destino (ctx.destinationCode indica cuál) y usa +// ctx.mapValue(fieldName, sourceValue) para resolver los mapeos de valor +// cargados en Postgres (ver docs/ESCENARIOS.md) — esa es la parte de +// "mapeo" dentro de la transformación: el archivo decide QUÉ campo mapear +// y QUÉ hacer si falta el valor, el dato en sí vive en `scenario_value_mappings`. +export interface ScenarioFilterContext { + originCode: string; + // GET arbitrario contra la base ORIGEN del escenario, reusando la misma + // sesión ya autenticada que trajo el registro principal (no abre un login + // nuevo). Sirve tanto para un GET por clave ('/BusinessPartners(...)') + // como para una consulta con $filter. + get(path: string): Promise<{ status: number; data: any }>; +} + +export interface ScenarioTransformContext { + destinationCode: string; + // Busca en scenario_value_mappings (para ESTE escenario y ESTE destino) el + // valor cargado para fieldName=sourceValue. undefined si no hay mapeo — + // el archivo decide qué hacer (tirar error, dejar sin traducir, etc.), acá + // no se asume nada. + mapValue(fieldName: string, sourceValue: string): string | undefined; +} + +export type ScenarioFilterFn = (record: Record, ctx: ScenarioFilterContext) => boolean | Promise; +export type ScenarioTransformFn = (record: Record, ctx: ScenarioTransformContext) => Record; + +export interface ScenarioModule { + filter: ScenarioFilterFn; + transform: ScenarioTransformFn; +} + +// Nombre de archivo a partir del code de un escenario (ej. 'BP_PROVEEDORES' +// -> 'bp_proveedores'). Usado tanto para cargar el módulo en runtime como +// para generar el scaffold en manage-config.ts — ambos deben usar esta +// misma función para no desincronizarse. +export function scenarioFileSlug(scenarioCode: string): string { + return scenarioCode.toLowerCase(); +} + +export async function loadScenarioModule(scenarioCode: string): Promise { + const fileSlug = scenarioFileSlug(scenarioCode); + const moduleUrl = new URL(`./scenarios/${fileSlug}.ts`, import.meta.url); + let mod: { filter?: unknown; transform?: unknown }; + try { + mod = await import(moduleUrl.href); + } catch (err) { + throw new Error( + `No existe (o no se pudo cargar) el archivo de escenario 'src/scenarios/${fileSlug}.ts': ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + if (typeof mod.filter !== 'function') { + throw new Error(`'src/scenarios/${fileSlug}.ts' no exporta una función 'filter'`); + } + if (typeof mod.transform !== 'function') { + throw new Error(`'src/scenarios/${fileSlug}.ts' no exporta una función 'transform'`); + } + return { filter: mod.filter as ScenarioFilterFn, transform: mod.transform as ScenarioTransformFn }; +} + +export function buildScenarioFilterContext( + originCode: string, + get: (path: string) => Promise<{ status: number; data: any }>, +): ScenarioFilterContext { + return { originCode, get }; +} + +export function buildScenarioTransformContext( + destinationCode: string, + scenarioId: number, + valueMappings: Array<{ scenarioId: number; databaseCode: string; fieldName: string; sourceValue: string; targetValue: string }>, +): ScenarioTransformContext { + return { + destinationCode, + mapValue(fieldName, sourceValue) { + const mapping = valueMappings.find( + (m) => m.scenarioId === scenarioId && m.databaseCode === destinationCode && m.fieldName === fieldName && m.sourceValue === sourceValue, + ); + return mapping?.targetValue; + }, + }; +} + +export function applyScenarioTransform( + transform: ScenarioTransformFn, + record: Record, + ctx: ScenarioTransformContext, +): Record { + const result = transform({ ...record }, ctx); + if (typeof result !== 'object' || result === null || Array.isArray(result)) { + throw new Error(`El transform de este escenario devolvió algo que no es un objeto JSON para destino '${ctx.destinationCode}'`); + } + return result; +} diff --git a/src/scenarios/bp_proveedores.ts b/src/scenarios/bp_proveedores.ts new file mode 100644 index 0000000..0ffb3e6 --- /dev/null +++ b/src/scenarios/bp_proveedores.ts @@ -0,0 +1,37 @@ +// Escenario BP_PROVEEDORES: BusinessPartners con CardType=cSupplier, +// origen HQ01, destinos SUB_01/SUB_02 — ver docs/ESCENARIOS.md. +import type { ScenarioFilterContext, ScenarioTransformContext } from '../scenarioTransform.ts'; + +// --- Filtro: decide si este escenario aplica al registro --- +// No necesita ctx acá: CardType ya viene en el payload de BusinessPartners. +export function filter(record: Record, ctx: ScenarioFilterContext): boolean { + return record.CardType === 'cSupplier'; +} + +// --- Transformación: arma el payload para un destino puntual --- +// Solo se replican estos campos — el resto del payload de HQ01 se descarta. +export function transform(record: Record, ctx: ScenarioTransformContext): Record { + // --- Mapeo: GroupCode vive en Postgres (scenario_value_mappings), no acá --- + const groupCode = record.GroupCode === undefined || record.GroupCode === null ? undefined : String(record.GroupCode); + const mappedGroupCode = groupCode === undefined ? undefined : ctx.mapValue('GroupCode', groupCode); + + if (groupCode !== undefined && mappedGroupCode === undefined) { + throw new Error(`GroupCode '${groupCode}' no tiene mapeo cargado para el destino '${ctx.destinationCode}'`); + } + + return { + CardCode: record.CardCode, + CardName: record.CardName, + CardType: record.CardType, + GroupCode: mappedGroupCode, + Phone1: record.Phone1, + Phone2: record.Phone2, + Notes: record.Notes, + FederalTaxID: record.FederalTaxID, + Currency: record.Currency, + DebitorAccount: record.DebitorAccount, + Valid: record.Valid, + Frozen: record.Frozen, + Properties1: record.Properties1, + }; +} diff --git a/src/scenarios/chartofaccounts.ts b/src/scenarios/chartofaccounts.ts new file mode 100644 index 0000000..29b42c7 --- /dev/null +++ b/src/scenarios/chartofaccounts.ts @@ -0,0 +1,14 @@ +// Escenario CHARTOFACCOUNTS: ChartOfAccounts, sin filtro, origen HQ01, +// destinos SUB_01/SUB_02 — ver docs/ESCENARIOS.md. +import type { ScenarioFilterContext, ScenarioTransformContext } from '../scenarioTransform.ts'; + +// --- Filtro: decide si este escenario aplica al registro --- +export function filter(record: Record, ctx: ScenarioFilterContext): boolean { + return true; // sin condición: aplica a todo el plan de cuentas +} + +// --- Transformación: arma el payload para un destino puntual --- +export function transform(record: Record, ctx: ScenarioTransformContext): Record { + // --- Mapeo: sin campos mapeados por ahora --- + return { ...record }; +} diff --git a/src/scenarios/journalentries.ts b/src/scenarios/journalentries.ts new file mode 100644 index 0000000..fd071b4 --- /dev/null +++ b/src/scenarios/journalentries.ts @@ -0,0 +1,41 @@ +// Escenario JOURNALENTRIES: JournalEntries, destinationMode=payload, origen +// HQ01, destino resuelto por U_ITC_Target, keyStrategy=udfLink vía +// U_ITC_Source_JdtNum — ver docs/ESCENARIOS.md. +import type { ScenarioFilterContext, ScenarioTransformContext } from '../scenarioTransform.ts'; + +// --- Filtro: solo se replica si el asiento tiene una base destino asignada --- +export function filter(record: Record, ctx: ScenarioFilterContext): boolean { + const target = record.U_ITC_Target; + return typeof target === 'string' && target.trim() !== ''; +} + +// --- Transformación: arma el payload para el destino resuelto por U_ITC_Target --- +// Sin Series por ahora (las series no son portables entre compañías — se +// revisa más adelante cómo relacionarlas). AccountCode ya debería existir en +// el destino porque lo replica el escenario CHARTOFACCOUNTS. +export function transform(record: Record, ctx: ScenarioTransformContext): Record { + const lines = Array.isArray(record.JournalEntryLines) ? record.JournalEntryLines : []; + + const journalEntryLines = lines.map((line: Record) => ({ + AccountCode: line.AccountCode, + Debit: line.Credit, + Credit: line.Debit, + ShortName: line.ShortName, + LineMemo: line.LineMemo, + Reference1: line.Reference1, + Reference2: line.Reference2, + TaxDate: line.TaxDate, + ProjectCode: line.ProjectCode, + CostingCode: line.CostingCode, + })); + + return { + ReferenceDate: record.ReferenceDate, + Memo: record.Memo, + Reference: record.Reference, + Reference2: record.Reference2, + TaxDate: record.TaxDate, + DueDate: record.DueDate, + JournalEntryLines: journalEntryLines, + }; +} diff --git a/src/scenarios/profitcenters.ts b/src/scenarios/profitcenters.ts new file mode 100644 index 0000000..d1b1c28 --- /dev/null +++ b/src/scenarios/profitcenters.ts @@ -0,0 +1,14 @@ +// Escenario PROFITCENTERS: ProfitCenters, sin filtro, origen HQ01, destinos +// SUB_01/SUB_02/SUB_03 — ver docs/ESCENARIOS.md. +import type { ScenarioFilterContext, ScenarioTransformContext } from '../scenarioTransform.ts'; + +// --- Filtro: decide si este escenario aplica al registro --- +export function filter(record: Record, ctx: ScenarioFilterContext): boolean { + return true; // sin condición: aplica a todos los ProfitCenters +} + +// --- Transformación: arma el payload para un destino puntual --- +export function transform(record: Record, ctx: ScenarioTransformContext): Record { + // --- Mapeo: sin campos mapeados por ahora --- + return { ...record }; +} diff --git a/src/scenarios/salespersons.ts b/src/scenarios/salespersons.ts new file mode 100644 index 0000000..9230154 --- /dev/null +++ b/src/scenarios/salespersons.ts @@ -0,0 +1,14 @@ +// Escenario SALESPERSONS: SalesPersons, sin filtro, origen HQ01, destinos +// SUB_01/SUB_03, keyStrategy=udfLink vía U_HQ_SlpCode — ver docs/ESCENARIOS.md. +import type { ScenarioFilterContext, ScenarioTransformContext } from '../scenarioTransform.ts'; + +// --- Filtro: decide si este escenario aplica al registro --- +export function filter(record: Record, ctx: ScenarioFilterContext): boolean { + return true; // sin condición: aplica a todos los SalesPersons +} + +// --- Transformación: arma el payload para un destino puntual --- +export function transform(record: Record, ctx: ScenarioTransformContext): Record { + // --- Mapeo: sin campos mapeados por ahora --- + return { ...record }; +} diff --git a/src/serviceLayer.ts b/src/serviceLayer.ts new file mode 100644 index 0000000..477d2ba --- /dev/null +++ b/src/serviceLayer.ts @@ -0,0 +1,56 @@ +import axios, { type AxiosInstance } from 'axios'; +import https from 'node:https'; +import type { ServiceLayerTarget } from './config.ts'; + +// Cliente mínimo del Service Layer: Login + GET/POST/PATCH con la cookie +// de sesión. No cachea la sesión entre corridas del worker (cada ciclo +// vuelve a loguearse) para no lidiar con expiración de B1SESSION (~30 min). +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.name} (${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(); + } + + async get(path: string) { + await this.ensureLoggedIn(); + return this.http.get(path); + } + + async post(path: string, body: unknown) { + await this.ensureLoggedIn(); + return this.http.post(path, body); + } + + async patch(path: string, body: unknown) { + await this.ensureLoggedIn(); + return this.http.patch(path, body); + } +} diff --git a/tools/sl.http b/tools/sl.http new file mode 100644 index 0000000..5e25b91 --- /dev/null +++ b/tools/sl.http @@ -0,0 +1,19 @@ +@url=https://sap10:50000/b1s/v2 + +### +# @no-reject-unauthorized +POST {{url}}/Login +Content-Type : application/json + +{ + "CompanyDB":"SBODemoCL", + "UserName": "manager", + "Password":"mngr" +} + +### +# @no-reject-unauthorized +GET {{url}}/BusinessPartners?$filter=CardType eq 'cSupplier' + + +### diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f70ac21 --- /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", "src/generated"] +}