diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..953d7ca --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# ============================================ +# MONDAY.COM +# ============================================ +MONDAY_API_KEY="PONER_API_KEY_DE_MONDAY" +MONDAY_API_URL="https://api.monday.com/v2" +MONDAY_WORKSPACE="Frasa-CAS" + +# Board IDs del workspace Frasa-CAS (ya creados) +MONDAY_BOARD_CONTACTOS="18427678476" +MONDAY_BOARD_DESARROLLOS="18427678485" +MONDAY_BOARD_UNIDADES="18427678487" +MONDAY_BOARD_LEADS="18427678493" +MONDAY_BOARD_OPORTUNIDADES="18427678497" +MONDAY_BOARD_APARTADO="18427678515" +MONDAY_BOARD_VENTAS="18427678520" + +# ============================================ +# BASE DE DATOS (fase API Gateway) +# ============================================ +DATABASE_URL="postgresql://frasa_user:CAMBIAR@postgres:5432/frasa_db?schema=public" +POSTGRES_USER=frasa_user +POSTGRES_PASSWORD=CAMBIAR +POSTGRES_DB=frasa_db +REDIS_URL="redis://redis:6379" + +# ============================================ +# SEGURIDAD +# ============================================ +JWT_SECRET="cambiar_por_secreto_largo_y_aleatorio" +JWT_EXPIRES_IN="7d" + +# ============================================ +# APP CONFIG +# ============================================ +NODE_ENV=development +PORT=3000 diff --git a/.gitignore b/.gitignore index aa45378..85016a7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ dist/ # Material de referencia (contiene datos personales de formatos reales) reference/ +# Datos locales del API Gateway +api-gateway/prisma/dev.db* +api-gateway/uploads/ + # Sistema .DS_Store *.log diff --git a/api-gateway/.dockerignore b/api-gateway/.dockerignore new file mode 100644 index 0000000..b8a7e91 --- /dev/null +++ b/api-gateway/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +uploads +prisma/dev.db +prisma/dev.db-journal +.env +*.log +.git diff --git a/api-gateway/Dockerfile b/api-gateway/Dockerfile new file mode 100644 index 0000000..579b90e --- /dev/null +++ b/api-gateway/Dockerfile @@ -0,0 +1,28 @@ +# ---- Build ---- +FROM node:22-alpine AS build +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY prisma ./prisma +RUN npm ci + +COPY tsconfig.json ./ +COPY src ./src +RUN npx prisma generate && npm run build + +# ---- Runner ---- +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production + +COPY package.json package-lock.json ./ +COPY prisma ./prisma +RUN npm ci --omit=dev + +COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=build /app/node_modules/@prisma/client ./node_modules/@prisma/client +COPY --from=build /app/dist ./dist + +EXPOSE 3000 +# Aplica migraciones pendientes antes de arrancar +CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"] diff --git a/api-gateway/README.md b/api-gateway/README.md new file mode 100644 index 0000000..1466462 --- /dev/null +++ b/api-gateway/README.md @@ -0,0 +1,66 @@ +# FRASA API Gateway + +Backend Node.js que conecta las 3 apps web del ecosistema FRASA (cotizador :3001, alta-clientes :3002, cobranza :3003) con Monday.com y da persistencia real (Prisma + SQLite en dev, Postgres 16 en prod). + +## Stack + +Express 5 · TypeScript estricto · zod · Prisma (SQLite dev) · multer 2 · vitest + +## Cómo correr en dev + +```bash +npm install +npx prisma migrate dev # crea/actualiza prisma/dev.db +npm run dev # tsx watch en :3000 +``` + +Las credenciales de Monday y los board IDs se leen del `.env` raíz del ecosistema (`../.env`); el servicio también acepta un `.env` local (Prisma CLI lo requiere para `DATABASE_URL`). El token NUNCA se imprime en logs. + +## Tests + +```bash +npm test # vitest: fórmulas del cotizador (fixture U-101), cobranza, monday.service con fetch mockeado +``` + +## Endpoints (prefijo /api) + +| Método | Ruta | Descripción | +|---|---|---| +| GET | `/api/health` | `{status, monday: 'up'|'down'}` | +| GET | `/api/unidades` | Inventario desde Monday (`?disponibles=true` filtra) | +| GET | `/api/unidades/:codigo` | Una unidad (404 si no existe) | +| POST | `/api/cotizaciones` | Calcula propuesta (server-side), persiste y crea Oportunidad en Monday | +| GET | `/api/cotizaciones` · `/api/cotizaciones/:id` | Consulta | +| POST | `/api/clientes` | Alta de cliente: persiste payload completo, crea Contacto y Apartado en Monday | +| GET | `/api/clientes/:id` | Cliente con documentos | +| POST | `/api/clientes/:id/documentos` | Multipart (campo `archivos`, máx 10, pdf/jpg/png, 10 MB) | +| GET | `/api/clientes/:id/documentos` | Lista documentos | +| POST | `/api/planes` | Crea plan de pago con calendario generado | +| GET | `/api/planes` · `/api/planes/:id` | Con saldo/status calculados | +| GET | `/api/planes/cobranza-del-dia` | Urgencias de cobranza (`?diasAnticipacion=7`) | +| POST | `/api/planes/:id/abonos` | Aplica abono (estricto al centavo) | +| POST | `/api/planes/:id/notas` | Agrega nota a la bitácora | +| POST | `/api/planes/:id/cancelar` | Cancela el plan | +| POST | `/api/webhooks/monday` | Handshake `challenge` + log de eventos | + +## Notas de arquitectura + +- **Monday service** (`src/services/monday.service.ts`): resuelve column IDs por TÍTULO en runtime (los slugs autogenerados nunca se hardcodean), paginación por cursor, retry con backoff ante 429/complejidad, `column_values` siempre via `JSON.stringify` en variables, cache de unidades 5 min con invalidación tras escrituras. +- **Cálculos del cotizador** (`src/services/cotizacion.service.ts`): port exacto del Excel de producción (quirks incluidos: Infonavit usa precio de lista en mensualidades; enganche bancario sobre precio lista). NO corregir. +- **Cobranza** (`src/services/cobranza.service.ts`): espejo de la lógica del frontend; los controllers mapean Prisma → dominio, ejecutan las funciones puras y persisten. +- **Dinero como `Float`** por ahora — TODO prod: `Decimal` en Postgres. +- **Webhooks de Monday no están firmados** → el payload se trata como no confiable; antes de actuar, re-consultar vía API (por ahora solo se loggea). + +## TODO producción (pendientes deliberados) + +- Auth (JWT ya hay vars en .env), helmet, rate-limit — es red interna por ahora. +- Postgres 16 (`provider = "postgresql"`, `Decimal`), Redis para cache de unidades. +- CORS restringido a los orígenes de las apps. + +## Docker + +```bash +docker build -t frasa-api-gateway . +docker run -p 3000:3000 --env-file ../.env frasa-api-gateway +# El arranque corre `prisma migrate deploy` antes de levantar el server +``` diff --git a/api-gateway/package-lock.json b/api-gateway/package-lock.json new file mode 100644 index 0000000..84eabac --- /dev/null +++ b/api-gateway/package-lock.json @@ -0,0 +1,3237 @@ +{ + "name": "frasa-api-gateway", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frasa-api-gateway", + "version": "0.1.0", + "dependencies": { + "@prisma/client": "^6.14.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^5.1.0", + "multer": "^2.0.2", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.3", + "@types/multer": "^2.0.0", + "@types/node": "^22.15.0", + "prisma": "^6.14.0", + "tsx": "^4.20.3", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + }, + "engines": { + "node": ">=22" + } + }, + "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/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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==", + "devOptional": true, + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "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/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "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/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/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "devOptional": true, + "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==", + "devOptional": true, + "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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "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/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/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/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "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==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/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/multer/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/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/nypm": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.9.tgz", + "integrity": "sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.2.4" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.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==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "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/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "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/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/api-gateway/package.json b/api-gateway/package.json new file mode 100644 index 0000000..4be7668 --- /dev/null +++ b/api-gateway/package.json @@ -0,0 +1,35 @@ +{ + "name": "frasa-api-gateway", + "version": "0.1.0", + "private": true, + "description": "API Gateway del ecosistema FRASA: conecta las apps web con Monday.com y da persistencia", + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "test": "vitest run", + "prisma:migrate": "prisma migrate dev" + }, + "dependencies": { + "@prisma/client": "^6.14.0", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^5.1.0", + "multer": "^2.0.2", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.3", + "@types/multer": "^2.0.0", + "@types/node": "^22.15.0", + "prisma": "^6.14.0", + "tsx": "^4.20.3", + "typescript": "^5.9.2", + "vitest": "^3.2.4" + } +} diff --git a/api-gateway/prisma/migrations/20260821224530_init/migration.sql b/api-gateway/prisma/migrations/20260821224530_init/migration.sql new file mode 100644 index 0000000..9100137 --- /dev/null +++ b/api-gateway/prisma/migrations/20260821224530_init/migration.sql @@ -0,0 +1,121 @@ +-- CreateTable +CREATE TABLE "Cotizacion" ( + "id" TEXT NOT NULL PRIMARY KEY, + "unidadCodigo" TEXT NOT NULL, + "clienteNombre" TEXT NOT NULL, + "clienteEmail" TEXT, + "clienteTelefono" TEXT, + "tipoPropuesta" TEXT NOT NULL, + "acabados" BOOLEAN NOT NULL, + "apartado" REAL NOT NULL, + "descuento" REAL NOT NULL, + "precioLista" REAL NOT NULL, + "precioFinal" REAL NOT NULL, + "enganche" REAL NOT NULL, + "numMensualidades" INTEGER NOT NULL, + "mensualidad" REAL, + "pagoFinal" REAL NOT NULL, + "mondayItemId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "Cliente" ( + "id" TEXT NOT NULL PRIMARY KEY, + "nombreCompleto" TEXT NOT NULL, + "curp" TEXT NOT NULL, + "rfc" TEXT NOT NULL, + "email" TEXT, + "celular" TEXT, + "proyecto" TEXT NOT NULL, + "unidadCodigo" TEXT, + "payloadJson" TEXT NOT NULL, + "firmaPng" TEXT, + "mondayContactoId" TEXT, + "mondayApartadoId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "Documento" ( + "id" TEXT NOT NULL PRIMARY KEY, + "clienteId" TEXT NOT NULL, + "tipo" TEXT NOT NULL, + "nombreArchivo" TEXT NOT NULL, + "ruta" TEXT NOT NULL, + "mime" TEXT NOT NULL, + "tamano" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Documento_clienteId_fkey" FOREIGN KEY ("clienteId") REFERENCES "Cliente" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "PlanPago" ( + "id" TEXT NOT NULL PRIMARY KEY, + "ventaRef" TEXT, + "clienteNombre" TEXT NOT NULL, + "clienteTelefono" TEXT, + "unidadCodigo" TEXT NOT NULL, + "proyecto" TEXT NOT NULL, + "fechaInicio" DATETIME NOT NULL, + "engancheTotal" REAL NOT NULL, + "numPagos" INTEGER NOT NULL, + "mensualidad" REAL NOT NULL, + "tasaMoraMensual" REAL NOT NULL DEFAULT 0, + "status" TEXT NOT NULL DEFAULT 'ACTIVO', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- CreateTable +CREATE TABLE "Pago" ( + "id" TEXT NOT NULL PRIMARY KEY, + "planId" TEXT NOT NULL, + "numPago" INTEGER NOT NULL, + "fechaVencimiento" DATETIME NOT NULL, + "monto" REAL NOT NULL, + "montoPagado" REAL NOT NULL DEFAULT 0, + CONSTRAINT "Pago_planId_fkey" FOREIGN KEY ("planId") REFERENCES "PlanPago" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AplicacionPago" ( + "id" TEXT NOT NULL PRIMARY KEY, + "pagoId" TEXT NOT NULL, + "fechaPago" DATETIME NOT NULL, + "monto" REAL NOT NULL, + "referencia" TEXT NOT NULL, + CONSTRAINT "AplicacionPago_pagoId_fkey" FOREIGN KEY ("pagoId") REFERENCES "Pago" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "NotaPlan" ( + "id" TEXT NOT NULL PRIMARY KEY, + "planId" TEXT NOT NULL, + "fecha" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "texto" TEXT NOT NULL, + CONSTRAINT "NotaPlan_planId_fkey" FOREIGN KEY ("planId") REFERENCES "PlanPago" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "Cotizacion_unidadCodigo_idx" ON "Cotizacion"("unidadCodigo"); + +-- CreateIndex +CREATE INDEX "Cliente_curp_idx" ON "Cliente"("curp"); + +-- CreateIndex +CREATE INDEX "Documento_clienteId_idx" ON "Documento"("clienteId"); + +-- CreateIndex +CREATE INDEX "PlanPago_unidadCodigo_idx" ON "PlanPago"("unidadCodigo"); + +-- CreateIndex +CREATE INDEX "Pago_planId_idx" ON "Pago"("planId"); + +-- CreateIndex +CREATE INDEX "Pago_fechaVencimiento_idx" ON "Pago"("fechaVencimiento"); + +-- CreateIndex +CREATE INDEX "AplicacionPago_pagoId_idx" ON "AplicacionPago"("pagoId"); + +-- CreateIndex +CREATE INDEX "NotaPlan_planId_idx" ON "NotaPlan"("planId"); diff --git a/api-gateway/prisma/migrations/migration_lock.toml b/api-gateway/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/api-gateway/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 = "sqlite" diff --git a/api-gateway/prisma/schema.prisma b/api-gateway/prisma/schema.prisma new file mode 100644 index 0000000..439ba09 --- /dev/null +++ b/api-gateway/prisma/schema.prisma @@ -0,0 +1,123 @@ +// FRASA API Gateway — persistencia local +// Desarrollo: SQLite. Producción: Postgres 16 (cambiar provider y DATABASE_URL). + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" // prod: "postgresql" + url = env("DATABASE_URL") +} + +model Cotizacion { + id String @id @default(uuid()) + unidadCodigo String + clienteNombre String + clienteEmail String? + clienteTelefono String? + tipoPropuesta String // BANCARIO | INFONAVIT | CONTADO | SEMI_CONTADO + acabados Boolean + apartado Float // TODO prod: Decimal en Postgres + descuento Float // fracción (0.02 = 2%) + precioLista Float // TODO prod: Decimal en Postgres + precioFinal Float + enganche Float + numMensualidades Int + mensualidad Float? // null => N/A (Contado) + pagoFinal Float + mondayItemId String? + createdAt DateTime @default(now()) + + @@index([unidadCodigo]) +} + +model Cliente { + id String @id @default(uuid()) + nombreCompleto String + curp String + rfc String + email String? + celular String? + proyecto String + unidadCodigo String? + // Payload completo del alta como JSON serializado (SQLite no tiene Json nativo; + // en Postgres puede migrarse a la columna Json sin cambiar el contrato). + payloadJson String + firmaPng String? // base64 opcional + mondayContactoId String? + mondayApartadoId String? + createdAt DateTime @default(now()) + documentos Documento[] + + @@index([curp]) +} + +model Documento { + id String @id @default(uuid()) + clienteId String + cliente Cliente @relation(fields: [clienteId], references: [id], onDelete: Cascade) + tipo String // INE | CONSTANCIA_RFC | CURP | COMPROBANTE_DOMICILIO | ACTA_NACIMIENTO | OTRO + nombreArchivo String + ruta String + mime String + tamano Int + createdAt DateTime @default(now()) + + @@index([clienteId]) +} + +model PlanPago { + id String @id @default(uuid()) + ventaRef String? + clienteNombre String + clienteTelefono String? + unidadCodigo String + proyecto String + fechaInicio DateTime // se normaliza a medianoche local + engancheTotal Float // TODO prod: Decimal en Postgres + numPagos Int + mensualidad Float + tasaMoraMensual Float @default(0) + status String @default("ACTIVO") // ACTIVO | CANCELADO (LIQUIDADO se deriva) + createdAt DateTime @default(now()) + notas NotaPlan[] + pagos Pago[] + + @@index([unidadCodigo]) +} + +model Pago { + id String @id @default(uuid()) + planId String + plan PlanPago @relation(fields: [planId], references: [id], onDelete: Cascade) + numPago Int + fechaVencimiento DateTime + monto Float + montoPagado Float @default(0) + aplicaciones AplicacionPago[] + + @@index([planId]) + @@index([fechaVencimiento]) +} + +model AplicacionPago { + id String @id @default(uuid()) + pagoId String + pago Pago @relation(fields: [pagoId], references: [id], onDelete: Cascade) + fechaPago DateTime + monto Float + referencia String + + @@index([pagoId]) +} + +model NotaPlan { + id String @id @default(uuid()) + planId String + plan PlanPago @relation(fields: [planId], references: [id], onDelete: Cascade) + fecha DateTime @default(now()) + texto String + + @@index([planId]) +} diff --git a/api-gateway/src/app.ts b/api-gateway/src/app.ts new file mode 100644 index 0000000..9f6afc5 --- /dev/null +++ b/api-gateway/src/app.ts @@ -0,0 +1,22 @@ +import express from 'express' +import cors from 'cors' +import { apiRouter } from './routes/index.js' +import { errorHandler } from './middleware/errorHandler.js' +import { requestLogger } from './middleware/requestLogger.js' + +export function crearApp(): express.Express { + const app = express() + + app.use(cors()) // CORS abierto solo para dev (red interna) + app.use(express.json({ limit: '10mb' })) + app.use(requestLogger) + + app.use('/api', apiRouter) + + app.use((_req, res) => { + res.status(404).json({ error: 'Ruta no encontrada' }) + }) + app.use(errorHandler) + + return app +} diff --git a/api-gateway/src/config/index.ts b/api-gateway/src/config/index.ts new file mode 100644 index 0000000..03fdad1 --- /dev/null +++ b/api-gateway/src/config/index.ts @@ -0,0 +1,34 @@ +import path from 'node:path' +import dotenv from 'dotenv' + +// Carga .env local si existe y luego el .env raíz del ecosistema (no pisa vars ya definidas) +dotenv.config({ path: [path.resolve(process.cwd(), '.env'), path.resolve(process.cwd(), '..', '.env')] }) + +function env(name: string, fallback?: string): string { + const v = process.env[name] ?? fallback + if (v === undefined) throw new Error(`Falta la variable de entorno ${name}`) + return v +} + +export const config = { + port: Number(env('PORT', '3000')), + nodeEnv: env('NODE_ENV', 'development'), + databaseUrl: env('DATABASE_URL', 'file:./dev.db'), + uploadsDir: path.resolve(process.cwd(), env('UPLOADS_DIR', './uploads')), + monday: { + apiKey: env('MONDAY_API_KEY'), + apiUrl: env('MONDAY_API_URL', 'https://api.monday.com/v2'), + apiVersion: '2024-10', + boards: { + contactos: env('MONDAY_BOARD_CONTACTOS'), + desarrollos: env('MONDAY_BOARD_DESARROLLOS'), + unidades: env('MONDAY_BOARD_UNIDADES'), + leads: env('MONDAY_BOARD_LEADS'), + oportunidades: env('MONDAY_BOARD_OPORTUNIDADES'), + apartado: env('MONDAY_BOARD_APARTADO'), + ventas: env('MONDAY_BOARD_VENTAS'), + }, + }, +} as const + +export type AppConfig = typeof config diff --git a/api-gateway/src/controllers/clientes.controller.ts b/api-gateway/src/controllers/clientes.controller.ts new file mode 100644 index 0000000..8b1ffbd --- /dev/null +++ b/api-gateway/src/controllers/clientes.controller.ts @@ -0,0 +1,191 @@ +import type { Request, Response } from 'express' +import path from 'node:path' +import fs from 'node:fs' +import { randomUUID } from 'node:crypto' +import multer from 'multer' +import { z } from 'zod' +import { config } from '../config/index.js' +import { mondayService } from '../services/monday.service.js' +import { prisma } from '../services/prisma.js' +import { NotFoundError, BadRequestError } from '../utils/errors.js' + +// ---------- Alta de clientes ---------- + +const crearClienteSchema = z + .object({ + datosGenerales: z + .object({ + nombreCompleto: z.string().min(1), + curp: z.string().min(1), + rfc: z.string().min(1), + email: z.string().optional(), + celular: z.string().optional(), + direccion: z.string().optional(), + }) + .passthrough(), + viviendaCredito: z + .object({ + proyecto: z.string().min(1), + ubicacion: z.string().min(1), + valorVenta: z.union([z.string(), z.number()]).optional(), + enganche: z.union([z.string(), z.number()]).optional(), + }) + .passthrough(), + firma: z.object({ firmaDataUrl: z.string().optional() }).passthrough().optional(), + }) + .passthrough() // el resto del payload pasa tal cual a payloadJson + +const aNumero = (v: string | number | undefined): number => { + if (v === undefined) return 0 + const n = typeof v === 'number' ? v : Number(String(v).replace(/[^0-9.-]/g, '')) + return Number.isFinite(n) ? n : 0 +} + +const hoyMas = (dias: number): string => { + const d = new Date() + d.setDate(d.getDate() + dias) + const m = String(d.getMonth() + 1).padStart(2, '0') + const dia = String(d.getDate()).padStart(2, '0') + return `${d.getFullYear()}-${m}-${dia}` +} + +/** POST /api/clientes — persiste el alta y la replica en Monday (Contactos y Apartado) */ +export async function crearCliente(req: Request, res: Response): Promise { + const body = crearClienteSchema.parse(req.body) + const dg = body.datosGenerales + const vc = body.viviendaCredito + + const cliente = await prisma.cliente.create({ + data: { + nombreCompleto: dg.nombreCompleto, + curp: dg.curp, + rfc: dg.rfc, + email: dg.email ?? null, + celular: dg.celular ?? null, + proyecto: vc.proyecto, + unidadCodigo: vc.ubicacion, // la "ubicación" del formato es el código de unidad (U-101, etc.) + payloadJson: JSON.stringify(body), + firmaPng: body.firma?.firmaDataUrl ?? null, + }, + }) + + // Réplica en Monday; si falla, el alta ya quedó persistida y se reportan ids null + let mondayContactoId: string | null = null + let mondayApartadoId: string | null = null + try { + mondayContactoId = await mondayService.crearContacto({ + nombre: dg.nombreCompleto, + estadoLabel: 'CLIENTE', + ...(dg.celular !== undefined ? { telefono: dg.celular } : {}), + ...(dg.email !== undefined ? { email: dg.email } : {}), + curp: dg.curp, + rfc: dg.rfc, + ...(dg.direccion !== undefined ? { direccion: dg.direccion } : {}), + }) + + const valorVenta = aNumero(vc.valorVenta) + if (valorVenta > 0) { + const enganche = aNumero(vc.enganche) + mondayApartadoId = await mondayService.crearApartado({ + clienteNombre: dg.nombreCompleto, + unidadCodigo: vc.ubicacion, + ...(enganche > 0 ? { montoApartado: enganche } : {}), + fechaApartado: hoyMas(0), + fechaLimite: hoyMas(15), + estadoLabel: 'VIGENTE', + }) + } + + await prisma.cliente.update({ + where: { id: cliente.id }, + data: { mondayContactoId, mondayApartadoId }, + }) + } catch (err) { + // eslint-disable-next-line no-console + console.error('No se pudo replicar el cliente en Monday:', err) + } + + res.status(201).json({ ...cliente, mondayContactoId, mondayApartadoId }) +} + +/** GET /api/clientes/:id */ +export async function obtenerCliente(req: Request, res: Response): Promise { + const cliente = await prisma.cliente.findUnique({ + where: { id: String(req.params['id']) }, + include: { documentos: true }, + }) + if (!cliente) throw new NotFoundError('Cliente no encontrado') + res.json(cliente) +} + +// ---------- Documentos ---------- + +const MIME_PERMITIDOS = new Set(['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']) +const EXT_POR_MIME: Record = { + 'application/pdf': 'pdf', + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', +} + +const storage = multer.diskStorage({ + destination: (req, _file, cb) => { + const dir = path.join(config.uploadsDir, String(req.params['id'])) + fs.mkdirSync(dir, { recursive: true }) + cb(null, dir) + }, + filename: (req, file, cb) => { + const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase() + const ext = EXT_POR_MIME[file.mimetype] ?? 'bin' + cb(null, `${tipo}_${randomUUID()}.${ext}`) + }, +}) + +export const uploadDocumentos = multer({ + storage, + limits: { fileSize: 10 * 1024 * 1024, files: 10 }, // 10 MB por archivo, máx 10 + fileFilter: (_req, file, cb) => { + if (!MIME_PERMITIDOS.has(file.mimetype)) { + cb(new BadRequestError(`Tipo de archivo no permitido: ${file.mimetype}`)) + return + } + cb(null, true) + }, +}) + +/** POST /api/clientes/:id/documentos — multipart, campo `archivos` (hasta 10) */ +export async function subirDocumentos(req: Request, res: Response): Promise { + const clienteId = String(req.params['id']) + const cliente = await prisma.cliente.findUnique({ where: { id: clienteId } }) + if (!cliente) throw new NotFoundError('Cliente no encontrado') + + const archivos = (req.files ?? []) as Express.Multer.File[] + const tipo = String(req.body['tipo'] ?? 'OTRO').toUpperCase() + const documentos = await Promise.all( + archivos.map((f) => + prisma.documento.create({ + data: { + clienteId, + tipo, + nombreArchivo: f.originalname, + ruta: path.relative(config.uploadsDir, f.path), + mime: f.mimetype, + tamano: f.size, + }, + }), + ), + ) + res.status(201).json(documentos) +} + +/** GET /api/clientes/:id/documentos */ +export async function listarDocumentos(req: Request, res: Response): Promise { + const clienteId = String(req.params['id']) + const cliente = await prisma.cliente.findUnique({ where: { id: clienteId } }) + if (!cliente) throw new NotFoundError('Cliente no encontrado') + const documentos = await prisma.documento.findMany({ + where: { clienteId }, + orderBy: { createdAt: 'desc' }, + }) + res.json(documentos) +} diff --git a/api-gateway/src/controllers/cotizaciones.controller.ts b/api-gateway/src/controllers/cotizaciones.controller.ts new file mode 100644 index 0000000..91e4886 --- /dev/null +++ b/api-gateway/src/controllers/cotizaciones.controller.ts @@ -0,0 +1,91 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' +import { mondayService } from '../services/monday.service.js' +import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js' +import { prisma } from '../services/prisma.js' +import { NotFoundError } from '../utils/errors.js' + +const crearCotizacionSchema = z.object({ + unidadCodigo: z.string().min(1), + acabados: z.boolean(), + apartado: z.number().positive().optional(), + clienteNombre: z.string().min(1), + clienteEmail: z.string().email().optional(), + clienteTelefono: z.string().min(7).optional(), + tipoPropuesta: z.enum(['BANCARIO', 'INFONAVIT', 'CONTADO', 'SEMI_CONTADO']), + descuento: z.number().min(0).max(1).optional(), // fracción (0.02 = 2%) +}) + +const hoyISO = (): string => { + const d = new Date() + const m = String(d.getMonth() + 1).padStart(2, '0') + const dia = String(d.getDate()).padStart(2, '0') + return `${d.getFullYear()}-${m}-${dia}` +} + +/** POST /api/cotizaciones — calcula, persiste y registra la oportunidad en Monday */ +export async function crearCotizacion(req: Request, res: Response): Promise { + const body = crearCotizacionSchema.parse(req.body) + + const unidad = await mondayService.getUnidad(body.unidadCodigo) + if (!unidad) throw new NotFoundError(`Unidad ${body.unidadCodigo} no encontrada`) + + const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT + const propuesta = calcularPropuesta(unidad, body.tipoPropuesta, body.acabados, apartado, body.descuento) + const precioLista = obtenerPrecioLista(unidad, body.acabados) + + const cotizacion = await prisma.cotizacion.create({ + data: { + unidadCodigo: unidad.codigo, + clienteNombre: body.clienteNombre, + clienteEmail: body.clienteEmail ?? null, + clienteTelefono: body.clienteTelefono ?? null, + tipoPropuesta: body.tipoPropuesta, + acabados: body.acabados, + apartado, + descuento: propuesta.descuento, + precioLista, + precioFinal: propuesta.precioFinal, + enganche: propuesta.enganche, + numMensualidades: propuesta.numMensualidades, + mensualidad: propuesta.mensualidad, + pagoFinal: propuesta.pagoFinal, + }, + }) + + // Registra la oportunidad en Monday; si Monday falla, la cotización ya quedó persistida + let mondayItemId: string | null = null + try { + mondayItemId = await mondayService.crearOportunidad({ + clienteNombre: body.clienteNombre, + unidadCodigo: unidad.codigo, + tipoPropuestaLabel: body.tipoPropuesta, + precioLista, + descuentoPct: propuesta.descuento * 100, + precioFinal: propuesta.precioFinal, + fechaCotizacion: hoyISO(), + estadoLabel: 'COTIZADA', + ...(body.clienteEmail !== undefined ? { clienteEmail: body.clienteEmail } : {}), + ...(body.clienteTelefono !== undefined ? { clienteTelefono: body.clienteTelefono } : {}), + }) + await prisma.cotizacion.update({ where: { id: cotizacion.id }, data: { mondayItemId } }) + } catch (err) { + // eslint-disable-next-line no-console + console.error('No se pudo crear la oportunidad en Monday:', err) + } + + res.status(201).json({ ...cotizacion, mondayItemId, propuesta }) +} + +/** GET /api/cotizaciones */ +export async function listarCotizaciones(_req: Request, res: Response): Promise { + const cotizaciones = await prisma.cotizacion.findMany({ orderBy: { createdAt: 'desc' } }) + res.json(cotizaciones) +} + +/** GET /api/cotizaciones/:id */ +export async function obtenerCotizacion(req: Request, res: Response): Promise { + const cotizacion = await prisma.cotizacion.findUnique({ where: { id: String(req.params['id']) } }) + if (!cotizacion) throw new NotFoundError('Cotización no encontrada') + res.json(cotizacion) +} diff --git a/api-gateway/src/controllers/health.controller.ts b/api-gateway/src/controllers/health.controller.ts new file mode 100644 index 0000000..9ce3701 --- /dev/null +++ b/api-gateway/src/controllers/health.controller.ts @@ -0,0 +1,8 @@ +import type { Request, Response } from 'express' +import { mondayService } from '../services/monday.service.js' + +/** GET /api/health — ping barato a Monday */ +export async function health(_req: Request, res: Response): Promise { + const mondayUp = await mondayService.ping() + res.json({ status: 'ok', monday: mondayUp ? 'up' : 'down' }) +} diff --git a/api-gateway/src/controllers/planes.controller.ts b/api-gateway/src/controllers/planes.controller.ts new file mode 100644 index 0000000..0e1ce1a --- /dev/null +++ b/api-gateway/src/controllers/planes.controller.ts @@ -0,0 +1,236 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' +import { prisma } from '../services/prisma.js' +import { + alCorriente, + avancePlan, + cobranzaDelDia, + construirNota, + diasAtraso, + generarCalendario, + pagadoTotal, + parseFechaLocal, + round2, + saldoPlan, + statusPago, + statusPlan, + totalMora, + validarAbono, + type PlanPago as PlanPagoDominio, +} from '../services/cobranza.service.js' +import { BadRequestError, NotFoundError } from '../utils/errors.js' +import type { AplicacionPago, NotaPlan, Pago, PlanPago } from '@prisma/client' + +// ---------- Mapeo Prisma → dominio (funciones puras del servicio) ---------- + +const aFechaISO = (d: Date): string => { + const m = String(d.getMonth() + 1).padStart(2, '0') + const dia = String(d.getDate()).padStart(2, '0') + return `${d.getFullYear()}-${m}-${dia}` +} + +type PlanCompleto = PlanPago & { + pagos: (Pago & { aplicaciones: AplicacionPago[] })[] + notas: NotaPlan[] +} + +function aDominio(plan: PlanCompleto): PlanPagoDominio { + return { + id: plan.id, + ventaRef: plan.ventaRef, + clienteNombre: plan.clienteNombre, + clienteTelefono: plan.clienteTelefono, + unidadCodigo: plan.unidadCodigo, + proyecto: plan.proyecto, + fechaInicio: aFechaISO(plan.fechaInicio), + engancheTotal: plan.engancheTotal, + numPagos: plan.numPagos, + mensualidad: plan.mensualidad, + tasaMoraMensual: plan.tasaMoraMensual, + status: plan.status === 'CANCELADO' ? 'CANCELADO' : 'ACTIVO', + createdAt: plan.createdAt.toISOString(), + pagos: plan.pagos + .slice() + .sort((a, b) => a.numPago - b.numPago) + .map((p) => ({ + id: p.id, + numPago: p.numPago, + fechaVencimiento: aFechaISO(p.fechaVencimiento), + monto: p.monto, + montoPagado: p.montoPagado, + aplicaciones: p.aplicaciones.map((a) => ({ + id: a.id, + fechaPago: a.fechaPago.toISOString(), + monto: a.monto, + referencia: a.referencia, + })), + })), + notas: plan.notas.map((n) => ({ id: n.id, fecha: n.fecha.toISOString(), texto: n.texto })), + } +} + +/** Plan con los campos calculados que la app de cobranza muestra */ +function conCalculados(plan: PlanPagoDominio) { + const hoy = new Date() + return { + ...plan, + status: statusPlan(plan), + saldo: saldoPlan(plan), + pagadoTotal: pagadoTotal(plan), + avance: avancePlan(plan), + alCorriente: alCorriente(plan, hoy), + diasAtraso: diasAtraso(plan, hoy), + totalMora: totalMora(plan, hoy), + pagos: plan.pagos.map((p) => ({ ...p, status: statusPago(p, hoy) })), + } +} + +const includeCompleto = { pagos: { include: { aplicaciones: true } }, notas: true } as const + +async function cargarPlan(id: string): Promise { + const plan = await prisma.planPago.findUnique({ where: { id }, include: includeCompleto }) + if (!plan) throw new NotFoundError('Plan de pago no encontrado') + return plan +} + +// ---------- Endpoints ---------- + +const crearPlanSchema = z.object({ + ventaRef: z.string().optional(), + clienteNombre: z.string().min(1), + clienteTelefono: z.string().optional(), + unidadCodigo: z.string().min(1), + proyecto: z.string().min(1), + fechaInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'fechaInicio debe ser YYYY-MM-DD'), + engancheTotal: z.number().positive(), + numPagos: z.number().int().min(1), + tasaMoraMensual: z.number().min(0).optional(), +}) + +/** POST /api/planes — genera el calendario de pagos y lo persiste */ +export async function crearPlan(req: Request, res: Response): Promise { + const body = crearPlanSchema.parse(req.body) + let calendario + try { + calendario = generarCalendario(body.fechaInicio, body.engancheTotal, body.numPagos) + } catch (err) { + throw new BadRequestError(err instanceof Error ? err.message : 'Parámetros inválidos') + } + + const plan = await prisma.planPago.create({ + data: { + ventaRef: body.ventaRef ?? null, + clienteNombre: body.clienteNombre, + clienteTelefono: body.clienteTelefono ?? null, + unidadCodigo: body.unidadCodigo, + proyecto: body.proyecto, + fechaInicio: parseFechaLocal(body.fechaInicio), + engancheTotal: body.engancheTotal, + numPagos: body.numPagos, + mensualidad: calendario.mensualidad, + tasaMoraMensual: body.tasaMoraMensual ?? 0, + pagos: { + create: calendario.pagos.map((p) => ({ + numPago: p.numPago, + fechaVencimiento: parseFechaLocal(p.fechaVencimiento), + monto: p.monto, + })), + }, + }, + include: includeCompleto, + }) + res.status(201).json(conCalculados(aDominio(plan))) +} + +/** GET /api/planes — con saldo/status calculados */ +export async function listarPlanes(_req: Request, res: Response): Promise { + const planes = await prisma.planPago.findMany({ + include: includeCompleto, + orderBy: { createdAt: 'desc' }, + }) + res.json(planes.map((p) => conCalculados(aDominio(p)))) +} + +/** GET /api/planes/cobranza-del-dia — lista plana de urgencias (antes de /:id) */ +export async function cobranzaHoy(req: Request, res: Response): Promise { + const dias = Number(req.query['diasAnticipacion'] ?? 7) + const planes = await prisma.planPago.findMany({ include: includeCompleto }) + const items = cobranzaDelDia( + planes.map(aDominio), + new Date(), + Number.isFinite(dias) ? dias : 7, + ) + res.json(items) +} + +/** GET /api/planes/:id */ +export async function obtenerPlan(req: Request, res: Response): Promise { + const plan = await cargarPlan(String(req.params['id'])) + res.json(conCalculados(aDominio(plan))) +} + +const abonoSchema = z.object({ + pagoId: z.string().min(1), + fechaPago: z.string().min(1), + monto: z.number(), + referencia: z.string().min(1), +}) + +/** POST /api/planes/:id/abonos — aplica un abono (estricto al centavo) */ +export async function registrarAbonoEndpoint(req: Request, res: Response): Promise { + const body = abonoSchema.parse(req.body) + const plan = await cargarPlan(String(req.params['id'])) + const dominio = aDominio(plan) + + let resultado + try { + resultado = validarAbono(dominio, body) + } catch (err) { + throw new BadRequestError(err instanceof Error ? err.message : 'Abono inválido') + } + const { pago, aplicacion } = resultado + + await prisma.$transaction([ + prisma.aplicacionPago.create({ + data: { + pagoId: pago.id, + fechaPago: new Date(body.fechaPago), + monto: aplicacion.monto, + referencia: aplicacion.referencia, + }, + }), + prisma.pago.update({ + where: { id: pago.id }, + data: { montoPagado: round2(pago.montoPagado + aplicacion.monto) }, + }), + ]) + + const actualizado = await cargarPlan(plan.id) + res.status(201).json(conCalculados(aDominio(actualizado))) +} + +const notaSchema = z.object({ texto: z.string() }) + +/** POST /api/planes/:id/notas */ +export async function agregarNota(req: Request, res: Response): Promise { + const { texto } = notaSchema.parse(req.body) + const plan = await cargarPlan(String(req.params['id'])) + let nota + try { + nota = construirNota(texto) + } catch (err) { + throw new BadRequestError(err instanceof Error ? err.message : 'Nota inválida') + } + const creada = await prisma.notaPlan.create({ + data: { planId: plan.id, texto: nota.texto }, + }) + res.status(201).json(creada) +} + +/** POST /api/planes/:id/cancelar */ +export async function cancelarPlan(req: Request, res: Response): Promise { + const plan = await cargarPlan(String(req.params['id'])) + await prisma.planPago.update({ where: { id: plan.id }, data: { status: 'CANCELADO' } }) + const actualizado = await cargarPlan(plan.id) + res.json(conCalculados(aDominio(actualizado))) +} diff --git a/api-gateway/src/controllers/unidades.controller.ts b/api-gateway/src/controllers/unidades.controller.ts new file mode 100644 index 0000000..20b57a7 --- /dev/null +++ b/api-gateway/src/controllers/unidades.controller.ts @@ -0,0 +1,17 @@ +import type { Request, Response } from 'express' +import { mondayService } from '../services/monday.service.js' +import { NotFoundError } from '../utils/errors.js' + +/** GET /api/unidades — ?disponibles=true filtra solo DISPONIBLE */ +export async function listarUnidades(req: Request, res: Response): Promise { + const unidades = await mondayService.getUnidades() + const disponibles = req.query['disponibles'] === 'true' + res.json(disponibles ? unidades.filter((u) => u.status === 'DISPONIBLE') : unidades) +} + +/** GET /api/unidades/:codigo — 404 si no existe */ +export async function obtenerUnidad(req: Request, res: Response): Promise { + const unidad = await mondayService.getUnidad(String(req.params['codigo'])) + if (!unidad) throw new NotFoundError(`Unidad ${String(req.params['codigo'])} no encontrada`) + res.json(unidad) +} diff --git a/api-gateway/src/controllers/webhooks.controller.ts b/api-gateway/src/controllers/webhooks.controller.ts new file mode 100644 index 0000000..f59ae46 --- /dev/null +++ b/api-gateway/src/controllers/webhooks.controller.ts @@ -0,0 +1,36 @@ +import type { Request, Response } from 'express' +import { z } from 'zod' + +const eventoSchema = z + .object({ + type: z.string().optional(), + boardId: z.union([z.string(), z.number()]).optional(), + itemId: z.union([z.string(), z.number()]).optional(), + }) + .passthrough() + +/** + * POST /api/webhooks/monday + * Handshake: Monday envía { challenge } al registrar el webhook y exige que + * se responda tal cual. Para eventos reales solo se loggea y se responde 200. + * + * OJO: Monday NO firma sus webhooks → el payload no es confiable. Antes de + * actuar sobre un evento hay que re-consultar el estado vía API (por ahora + * solo se registra el evento). + */ +export function webhookMonday(req: Request, res: Response): void { + const body = req.body as Record + + if (typeof body?.['challenge'] === 'string') { + res.json({ challenge: body['challenge'] }) + return + } + + const parsed = eventoSchema.safeParse(body) + if (parsed.success) { + const { type, boardId, itemId } = parsed.data + // eslint-disable-next-line no-console + console.log(`[webhook monday] tipo=${type ?? 'desconocido'} board=${boardId ?? '?'} item=${itemId ?? '?'}`) + } + res.sendStatus(200) +} diff --git a/api-gateway/src/index.ts b/api-gateway/src/index.ts new file mode 100644 index 0000000..93a9b8d --- /dev/null +++ b/api-gateway/src/index.ts @@ -0,0 +1,9 @@ +import { crearApp } from './app.js' +import { config } from './config/index.js' + +const app = crearApp() + +app.listen(config.port, () => { + // eslint-disable-next-line no-console + console.log(`API Gateway FRASA escuchando en http://localhost:${config.port} (${config.nodeEnv})`) +}) diff --git a/api-gateway/src/middleware/errorHandler.ts b/api-gateway/src/middleware/errorHandler.ts new file mode 100644 index 0000000..f0b8ec9 --- /dev/null +++ b/api-gateway/src/middleware/errorHandler.ts @@ -0,0 +1,23 @@ +import type { NextFunction, Request, Response } from 'express' +import { ZodError } from 'zod' +import { MulterError } from 'multer' +import { AppError } from '../utils/errors.js' + +/** Error handler central: zod → 400 con issues, AppError → su status, resto → 500 */ +export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction): void { + if (err instanceof ZodError) { + res.status(400).json({ error: 'Validación fallida', issues: err.issues }) + return + } + if (err instanceof MulterError) { + res.status(400).json({ error: `Error de carga de archivos: ${err.message}` }) + return + } + if (err instanceof AppError) { + res.status(err.status).json({ error: err.message }) + return + } + // eslint-disable-next-line no-console + console.error(`[500] ${req.method} ${req.originalUrl}:`, err) + res.status(500).json({ error: 'Error interno del servidor' }) +} diff --git a/api-gateway/src/middleware/requestLogger.ts b/api-gateway/src/middleware/requestLogger.ts new file mode 100644 index 0000000..472a47a --- /dev/null +++ b/api-gateway/src/middleware/requestLogger.ts @@ -0,0 +1,11 @@ +import type { NextFunction, Request, Response } from 'express' + +/** Logger de requests simple: método, ruta, status y duración */ +export function requestLogger(req: Request, res: Response, next: NextFunction): void { + const inicio = Date.now() + res.on('finish', () => { + // eslint-disable-next-line no-console + console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - inicio}ms`) + }) + next() +} diff --git a/api-gateway/src/routes/index.ts b/api-gateway/src/routes/index.ts new file mode 100644 index 0000000..1da8e65 --- /dev/null +++ b/api-gateway/src/routes/index.ts @@ -0,0 +1,51 @@ +import { Router } from 'express' +import { health } from '../controllers/health.controller.js' +import { listarUnidades, obtenerUnidad } from '../controllers/unidades.controller.js' +import { + crearCotizacion, + listarCotizaciones, + obtenerCotizacion, +} from '../controllers/cotizaciones.controller.js' +import { + crearCliente, + listarDocumentos, + obtenerCliente, + subirDocumentos, + uploadDocumentos, +} from '../controllers/clientes.controller.js' +import { + agregarNota, + cancelarPlan, + cobranzaHoy, + crearPlan, + listarPlanes, + obtenerPlan, + registrarAbonoEndpoint, +} from '../controllers/planes.controller.js' +import { webhookMonday } from '../controllers/webhooks.controller.js' + +export const apiRouter = Router() + +apiRouter.get('/health', health) + +apiRouter.get('/unidades', listarUnidades) +apiRouter.get('/unidades/:codigo', obtenerUnidad) + +apiRouter.post('/cotizaciones', crearCotizacion) +apiRouter.get('/cotizaciones', listarCotizaciones) +apiRouter.get('/cotizaciones/:id', obtenerCotizacion) + +apiRouter.post('/clientes', crearCliente) +apiRouter.get('/clientes/:id', obtenerCliente) +apiRouter.post('/clientes/:id/documentos', uploadDocumentos.array('archivos', 10), subirDocumentos) +apiRouter.get('/clientes/:id/documentos', listarDocumentos) + +apiRouter.post('/planes', crearPlan) +apiRouter.get('/planes', listarPlanes) +apiRouter.get('/planes/cobranza-del-dia', cobranzaHoy) +apiRouter.get('/planes/:id', obtenerPlan) +apiRouter.post('/planes/:id/abonos', registrarAbonoEndpoint) +apiRouter.post('/planes/:id/notas', agregarNota) +apiRouter.post('/planes/:id/cancelar', cancelarPlan) + +apiRouter.post('/webhooks/monday', webhookMonday) diff --git a/api-gateway/src/services/cobranza.service.test.ts b/api-gateway/src/services/cobranza.service.test.ts new file mode 100644 index 0000000..cea8718 --- /dev/null +++ b/api-gateway/src/services/cobranza.service.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from 'vitest' +import { + alCorriente, + avancePlan, + cobranzaDelDia, + construirNota, + diasAtraso, + generarCalendario, + moraDePago, + pagadoTotal, + proximoVencimiento, + round2, + saldoPlan, + statusPago, + statusPlan, + sumarMeses, + totalMora, + validarAbono, + type AbonoInput, + type Pago, + type PlanPago, +} from './cobranza.service.js' + +const pagoBase = (override: Partial = {}): Pago => ({ + id: 'p1', + numPago: 1, + fechaVencimiento: '2026-02-15', + monto: 10000, + montoPagado: 0, + aplicaciones: [], + ...override, +}) + +/** + * Helper de test: valida con `validarAbono` (la función real del servicio) y + * devuelve una copia del plan con el abono aplicado — el equivalente en + * memoria de lo que el controller persiste en Prisma. + */ +function aplicarAbono(plan: PlanPago, abono: AbonoInput): PlanPago { + const { pago, aplicacion } = validarAbono(plan, abono) + return { + ...plan, + pagos: plan.pagos.map((p) => + p.id === pago.id + ? { ...p, montoPagado: round2(p.montoPagado + aplicacion.monto), aplicaciones: [...p.aplicaciones, aplicacion] } + : p, + ), + } +} + +function planDePrueba(): PlanPago { + const { mensualidad, pagos } = generarCalendario('2026-01-10', 30000, 3) + return { + id: 'plan-1', + ventaRef: 'VTA-001', + clienteNombre: 'Cliente Prueba', + unidadCodigo: 'U-1', + proyecto: 'SENDA', + fechaInicio: '2026-01-10', + engancheTotal: 30000, + numPagos: 3, + mensualidad, + status: 'ACTIVO', + pagos, + notas: [], + createdAt: '2026-01-01T00:00:00.000Z', + } +} + +describe('generarCalendario', () => { + it('genera 12 pagos cuya suma es exactamente el enganche', () => { + const { mensualidad, pagos } = generarCalendario('2026-01-15', 180000, 12) + expect(pagos).toHaveLength(12) + expect(mensualidad).toBe(15000) + expect(round2(pagos.reduce((acc, p) => acc + p.monto, 0))).toBe(180000) + expect(pagos[0]?.numPago).toBe(1) + expect(pagos[11]?.numPago).toBe(12) + expect(pagos[0]?.fechaVencimiento).toBe('2026-01-15') + expect(pagos[11]?.fechaVencimiento).toBe('2026-12-15') + }) + + it('el último pago absorbe el residuo del redondeo (100000 / 3)', () => { + const { mensualidad, pagos } = generarCalendario('2026-03-01', 100000, 3) + expect(mensualidad).toBe(33333.33) + expect(pagos.map((p) => p.monto)).toEqual([33333.33, 33333.33, 33333.34]) + expect(round2(pagos.reduce((acc, p) => acc + p.monto, 0))).toBe(100000) + }) + + it('ajusta el día cuando el mes no lo tiene (31 de enero → 28 de febrero)', () => { + const { pagos } = generarCalendario('2025-01-31', 30000, 3) + expect(pagos[0]?.fechaVencimiento).toBe('2025-01-31') + expect(pagos[1]?.fechaVencimiento).toBe('2025-02-28') + expect(pagos[2]?.fechaVencimiento).toBe('2025-03-31') + }) + + it('respeta año bisiesto (31 de enero 2024 → 29 de febrero)', () => { + expect(sumarMeses(new Date(2024, 0, 31), 1)).toEqual(new Date(2024, 1, 29)) + }) + + it('rechaza parámetros inválidos', () => { + expect(() => generarCalendario('2026-01-01', 0, 6)).toThrow() + expect(() => generarCalendario('2026-01-01', 1000, 0)).toThrow() + }) +}) + +describe('statusPago', () => { + const hoy = new Date('2026-02-16T00:00:00') + + it('PAGADO cuando montoPagado cubre el monto', () => { + expect(statusPago(pagoBase({ montoPagado: 10000 }), hoy)).toBe('PAGADO') + }) + + it('PARCIAL con abono menor al monto, aunque ya venció', () => { + expect(statusPago(pagoBase({ montoPagado: 4000 }), hoy)).toBe('PARCIAL') + }) + + it('VENCIDO sin abono y hoy posterior al vencimiento', () => { + expect(statusPago(pagoBase(), hoy)).toBe('VENCIDO') + }) + + it('PENDIENTE sin abono y hoy en o antes del vencimiento', () => { + expect(statusPago(pagoBase(), new Date('2026-02-15T00:00:00'))).toBe('PENDIENTE') + expect(statusPago(pagoBase(), new Date('2026-02-01T00:00:00'))).toBe('PENDIENTE') + }) +}) + +describe('validarAbono', () => { + it('aplica un abono parcial y lo agrega al historial', () => { + const plan = planDePrueba() + const actualizado = aplicarAbono(plan, { + pagoId: plan.pagos[0]!.id, + fechaPago: '2026-01-10', + monto: 4000, + referencia: 'SPEI-123', + }) + expect(actualizado.pagos[0]?.montoPagado).toBe(4000) + expect(actualizado.pagos[0]?.aplicaciones).toHaveLength(1) + expect(actualizado.pagos[0]?.aplicaciones[0]?.referencia).toBe('SPEI-123') + expect(plan.pagos[0]?.montoPagado).toBe(0) // no muta el original + }) + + it('permite completar un pago con varios abonos', () => { + let plan = planDePrueba() + plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 4000, referencia: 'A1' }) + plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-11', monto: 6000, referencia: 'A2' }) + expect(plan.pagos[0]?.montoPagado).toBe(10000) + expect(plan.pagos[0]?.aplicaciones).toHaveLength(2) + expect(statusPago(plan.pagos[0]!, new Date('2026-01-12T00:00:00'))).toBe('PAGADO') + }) + + it('rechaza abonos que exceden el restante (estricto al centavo)', () => { + const plan = planDePrueba() + expect(() => + validarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 10000.01, referencia: 'X1' }), + ).toThrow(/excede el restante/) + }) + + it('rechaza abonos menores o iguales a 0', () => { + const plan = planDePrueba() + expect(() => + validarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 0, referencia: 'X1' }), + ).toThrow(/mayor a 0/) + expect(() => + validarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: -500, referencia: 'X1' }), + ).toThrow(/mayor a 0/) + }) + + it('rechaza un pagoId inexistente', () => { + const plan = planDePrueba() + expect(() => + validarAbono(plan, { pagoId: 'no-existe', fechaPago: '2026-01-10', monto: 100, referencia: 'X1' }), + ).toThrow(/no encontrado/) + }) +}) + +describe('saldo, avance y status del plan', () => { + it('saldoPlan y pagadoTotal reflejan los abonos', () => { + let plan = planDePrueba() + expect(saldoPlan(plan)).toBe(30000) + expect(pagadoTotal(plan)).toBe(0) + plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 10000, referencia: 'A1' }) + expect(saldoPlan(plan)).toBe(20000) + expect(pagadoTotal(plan)).toBe(10000) + expect(avancePlan(plan)).toBeCloseTo(1 / 3, 5) + }) + + it('el plan queda LIQUIDADO al cubrir todo el enganche', () => { + let plan = planDePrueba() + for (const pago of plan.pagos) { + plan = aplicarAbono(plan, { + pagoId: pago.id, + fechaPago: '2026-03-10', + monto: pago.monto - pago.montoPagado, + referencia: `LIQ-${pago.numPago}`, + }) + } + expect(saldoPlan(plan)).toBe(0) + expect(statusPlan(plan)).toBe('LIQUIDADO') + }) + + it('CANCELADO prevalece aunque tenga saldo', () => { + const plan = { ...planDePrueba(), status: 'CANCELADO' as const } + expect(statusPlan(plan)).toBe('CANCELADO') + }) + + it('alCorriente es falso cuando hay pagos vencidos', () => { + expect(alCorriente(planDePrueba(), new Date('2026-02-15T00:00:00'))).toBe(false) + expect(alCorriente(planDePrueba(), new Date('2026-01-05T00:00:00'))).toBe(true) + }) +}) + +describe('proximoVencimiento y diasAtraso', () => { + it('devuelve el primer pago no cubierto con fecha >= hoy', () => { + const prox = proximoVencimiento(planDePrueba(), new Date('2026-01-20T00:00:00')) + expect(prox?.numPago).toBe(2) + }) + + it('si no hay futuros, devuelve el vencido más antiguo', () => { + const prox = proximoVencimiento(planDePrueba(), new Date('2026-04-01T00:00:00')) + expect(prox?.numPago).toBe(1) + }) + + it('devuelve null cuando todo está cubierto', () => { + let plan = planDePrueba() + for (const pago of plan.pagos) { + plan = aplicarAbono(plan, { pagoId: pago.id, fechaPago: '2026-01-10', monto: pago.monto, referencia: 'X' }) + } + expect(proximoVencimiento(plan, new Date('2026-01-15T00:00:00'))).toBeNull() + }) + + it('diasAtraso cuenta desde el vencido más antiguo', () => { + expect(diasAtraso(planDePrueba(), new Date('2026-01-25T00:00:00'))).toBe(15) + expect(diasAtraso(planDePrueba(), new Date('2026-01-10T00:00:00'))).toBe(0) + }) + + it('un pago PARCIAL vencido no cuenta como atraso', () => { + let plan = planDePrueba() + plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 100, referencia: 'A' }) + expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(0) + }) +}) + +describe('moraDePago y totalMora', () => { + const hoy = new Date('2026-03-17T00:00:00') // pago de 10000 vencido el 15-feb → 30 días + + it('es 0 cuando la tasa es 0, cuando no está vencido o cuando ya está cubierto', () => { + expect(moraDePago(pagoBase(), 0, hoy)).toBe(0) + expect(moraDePago(pagoBase(), 2, new Date('2026-02-15T00:00:00'))).toBe(0) + expect(moraDePago(pagoBase({ montoPagado: 10000 }), 2, hoy)).toBe(0) + }) + + it('cálculo exacto: restante × (tasa/100) / 30 × días vencidos', () => { + expect(moraDePago(pagoBase(), 2, hoy)).toBe(200) // 10000 × 0.02 / 30 × 30 + expect(moraDePago(pagoBase(), 3, new Date('2026-02-25T00:00:00'))).toBe(100) // × 0.03 / 30 × 10 + }) + + it('un pago PARCIAL vencido genera mora sobre su restante', () => { + expect(moraDePago(pagoBase({ montoPagado: 4000 }), 2, hoy)).toBe(120) + }) + + it('totalMora suma la mora de todos los pagos y usa tasa 0 por defecto', () => { + const plan = planDePrueba() // vence 10-ene, 10-feb, 10-mar; hoy 20-mar + const hoyPlan = new Date('2026-03-20T00:00:00') + expect(totalMora(plan, hoyPlan)).toBe(0) + const conTasa: PlanPago = { ...plan, tasaMoraMensual: 3 } + expect(totalMora(conTasa, hoyPlan)).toBe(1170) // 690 + 380 + 100 + }) +}) + +describe('cobranzaDelDia', () => { + it('incluye vencidos y los que vencen en los próximos 7 días, ordenados por urgencia', () => { + const hoy = new Date('2026-03-05T00:00:00') + const items = cobranzaDelDia([planDePrueba()], hoy) + expect(items.map((i) => i.pago.numPago)).toEqual([1, 2, 3]) + expect(items.map((i) => i.atraso)).toEqual([54, 23, 0]) + }) + + it('excluye pagos cubiertos, planes no activos y vencimientos lejanos', () => { + const hoy = new Date('2026-01-05T00:00:00') + let plan = planDePrueba() + plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-05', monto: 10000, referencia: 'A' }) + const cancelado: PlanPago = { ...planDePrueba(), status: 'CANCELADO' } + expect(cobranzaDelDia([plan, cancelado], hoy)).toHaveLength(0) + const items40 = cobranzaDelDia([plan, cancelado], hoy, 40) + expect(items40.map((i) => i.pago.numPago)).toEqual([2]) + }) + + it('un PARCIAL vencido aparece con sus días de atraso', () => { + const hoy = new Date('2026-02-01T00:00:00') + let plan = planDePrueba() + plan = aplicarAbono(plan, { pagoId: plan.pagos[0]!.id, fechaPago: '2026-01-10', monto: 4000, referencia: 'A' }) + const items = cobranzaDelDia([plan], hoy) + expect(items[0]?.pago.numPago).toBe(1) + expect(items[0]?.atraso).toBe(22) + }) +}) + +describe('construirNota', () => { + it('construye la nota con texto limpio, id y fecha', () => { + const nota = construirNota(' Cliente promete pago el viernes ') + expect(nota.texto).toBe('Cliente promete pago el viernes') + expect(nota.id).toBeTruthy() + expect(nota.fecha).toBeTruthy() + }) + + it('rechaza notas de menos de 3 caracteres', () => { + expect(() => construirNota(' ok ')).toThrow(/al menos 3/) + expect(() => construirNota('')).toThrow(/al menos 3/) + }) +}) diff --git a/api-gateway/src/services/cobranza.service.ts b/api-gateway/src/services/cobranza.service.ts new file mode 100644 index 0000000..8cd6c8f --- /dev/null +++ b/api-gateway/src/services/cobranza.service.ts @@ -0,0 +1,316 @@ +import { randomUUID } from 'node:crypto' + +/** + * Lógica de cobranza espejo de apps/cobranza/src/lib/cobranza.ts. + * Son funciones puras: los controllers mapean los registros de Prisma a + * estas formas, ejecutan la lógica y persisten los cambios. + */ + +export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL' +export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO' + +export interface AplicacionPago { + id: string + fechaPago: string // ISO + monto: number + referencia: string +} + +export interface Pago { + id: string + numPago: number + fechaVencimiento: string // 'YYYY-MM-DD' + monto: number + montoPagado: number + aplicaciones: AplicacionPago[] +} + +export interface NotaPlan { + id: string + fecha: string // ISO + texto: string +} + +export interface PlanPago { + id: string + ventaRef?: string | null + clienteNombre: string + clienteTelefono?: string | null + unidadCodigo: string + proyecto: string + fechaInicio: string // 'YYYY-MM-DD' + engancheTotal: number + numPagos: number + mensualidad: number + tasaMoraMensual?: number + status: StatusPlan + pagos: Pago[] + notas: NotaPlan[] + createdAt: string // ISO +} + +/** Redondeo monetario a 2 decimales */ +export const round2 = (x: number): number => Math.round(x * 100) / 100 + +/** Tolerancia para comparaciones de dinero (centavos) */ +export const EPS = 0.01 + +const gte = (a: number, b: number): boolean => a >= b - EPS + +/** Parsea 'YYYY-MM-DD' como fecha LOCAL (nunca `new Date(iso)` a secas, que interpreta UTC) */ +export function parseFechaLocal(iso: string): Date { + return new Date(`${iso}T00:00:00`) +} + +function aISO(fecha: Date): string { + const y = fecha.getFullYear() + const m = String(fecha.getMonth() + 1).padStart(2, '0') + const d = String(fecha.getDate()).padStart(2, '0') + return `${y}-${m}-${d}` +} + +/** Último día del mes (mes 0-based) */ +function ultimoDiaDelMes(anio: number, mes: number): number { + return new Date(anio, mes + 1, 0).getDate() +} + +/** + * Suma `meses` conservando el día del mes; si el mes destino no tiene ese día, + * usa su último día (p. ej. 31 de enero + 1 mes → 28/29 de febrero). + */ +export function sumarMeses(fecha: Date, meses: number): Date { + const totalMeses = fecha.getMonth() + meses + const anio = fecha.getFullYear() + Math.floor(totalMeses / 12) + const mes = ((totalMeses % 12) + 12) % 12 + const dia = Math.min(fecha.getDate(), ultimoDiaDelMes(anio, mes)) + return new Date(anio, mes, dia) +} + +/** Compara solo la parte de fecha (ignora horas) */ +function compararFechas(a: Date, b: Date): number { + const da = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime() + const db = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime() + return da - db +} + +export interface CalendarioGenerado { + mensualidad: number + pagos: Pago[] +} + +/** + * Genera N pagos con vencimiento mensual a partir de `fechaInicio`. + * mensualidad = round2(engancheTotal / numPagos); el ÚLTIMO pago absorbe + * el residuo del redondeo para que la suma sea exactamente `engancheTotal`. + */ +export function generarCalendario( + fechaInicio: string, + engancheTotal: number, + numPagos: number, +): CalendarioGenerado { + if (numPagos < 1) throw new Error('numPagos debe ser al menos 1') + if (engancheTotal <= 0) throw new Error('engancheTotal debe ser mayor a 0') + const inicio = parseFechaLocal(fechaInicio) + const mensualidad = round2(engancheTotal / numPagos) + const pagos: Pago[] = [] + for (let i = 0; i < numPagos; i++) { + const monto = + i < numPagos - 1 ? mensualidad : round2(engancheTotal - mensualidad * (numPagos - 1)) + pagos.push({ + id: randomUUID(), + numPago: i + 1, + fechaVencimiento: aISO(sumarMeses(inicio, i)), + monto, + montoPagado: 0, + aplicaciones: [], + }) + } + return { mensualidad, pagos } +} + +/** + * Estado de un pago a la fecha `hoy`. + * PAGADO si cubrió el monto; PARCIAL si tiene abonos sin cubrir; sin abonos: + * VENCIDO si hoy > fechaVencimiento, si no PENDIENTE. + * Decisión de negocio: un pago PARCIAL cuya fecha ya venció sigue siendo + * PARCIAL (tiene abono aplicado); el atraso se señala vía `diasAtraso`. + */ +export function statusPago(pago: Pago, hoy: Date = new Date()): StatusPago { + if (gte(pago.montoPagado, pago.monto)) return 'PAGADO' + if (pago.montoPagado > EPS) return 'PARCIAL' + return compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) > 0 ? 'VENCIDO' : 'PENDIENTE' +} + +/** Saldo pendiente del plan: suma(monto - montoPagado) */ +export function saldoPlan(plan: PlanPago): number { + return round2(plan.pagos.reduce((acc, p) => acc + (p.monto - p.montoPagado), 0)) +} + +/** Total cobrado (suma de lo aplicado a todos los pagos) */ +export function pagadoTotal(plan: PlanPago): number { + return round2(plan.pagos.reduce((acc, p) => acc + p.montoPagado, 0)) +} + +/** Fracción de avance 0..1 (pagado / engancheTotal) */ +export function avancePlan(plan: PlanPago): number { + return plan.engancheTotal > 0 ? pagadoTotal(plan) / plan.engancheTotal : 0 +} + +const restante = (p: Pago): number => round2(p.monto - p.montoPagado) +const estaCubierto = (p: Pago): boolean => gte(p.montoPagado, p.monto) + +/** + * Primer pago no cubierto con fecha >= hoy; si no hay, el vencido más antiguo. + * Devuelve null si el plan está cubierto por completo. + */ +export function proximoVencimiento(plan: PlanPago, hoy: Date = new Date()): Pago | null { + const noCubiertos = plan.pagos.filter((p) => !estaCubierto(p)) + const futuro = noCubiertos.find((p) => compararFechas(parseFechaLocal(p.fechaVencimiento), hoy) >= 0) + if (futuro) return futuro + const vencidos = noCubiertos.sort( + (a, b) => parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(), + ) + return vencidos[0] ?? null +} + +/** Días naturales hasta el próximo vencimiento; null si el plan está cubierto */ +export function diasParaProximoVencimiento(plan: PlanPago, hoy: Date = new Date()): number | null { + const prox = proximoVencimiento(plan, hoy) + if (!prox) return null + return Math.round(compararFechas(parseFechaLocal(prox.fechaVencimiento), hoy) / 86400000) +} + +/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin abono) */ +export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number { + const vencidos = plan.pagos + .filter((p) => statusPago(p, hoy) === 'VENCIDO') + .sort( + (a, b) => + parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(), + ) + const primero = vencidos[0] + if (!primero) return 0 + return Math.round(compararFechas(hoy, parseFechaLocal(primero.fechaVencimiento)) / 86400000) +} + +/** Status derivado: CANCELADO solo manual; LIQUIDADO si saldo == 0; si no, ACTIVO */ +export function statusPlan(plan: PlanPago): StatusPlan { + if (plan.status === 'CANCELADO') return 'CANCELADO' + return saldoPlan(plan) <= EPS ? 'LIQUIDADO' : 'ACTIVO' +} + +/** Al corriente = sin pagos VENCIDOS */ +export function alCorriente(plan: PlanPago, hoy: Date = new Date()): boolean { + return !plan.pagos.some((p) => statusPago(p, hoy) === 'VENCIDO') +} + +/** + * Mora simple de un pago: restante × (tasa% / 100) / 30 × días vencidos. + * Solo aplica a pagos vencidos con saldo; un PARCIAL vencido también genera + * mora sobre su restante. Tasa 0 (o negativa) = sin mora. + */ +export function moraDePago(pago: Pago, tasaMoraMensual: number, hoy: Date = new Date()): number { + if (tasaMoraMensual <= 0) return 0 + const falta = restante(pago) + if (falta <= EPS) return 0 + const diasVencido = Math.round(compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) / 86400000) + if (diasVencido <= 0) return 0 + return round2((falta * (tasaMoraMensual / 100) / 30) * diasVencido) +} + +/** Mora acumulada del plan a la fecha `hoy` (usa plan.tasaMoraMensual ?? 0) */ +export function totalMora(plan: PlanPago, hoy: Date = new Date()): number { + const tasa = plan.tasaMoraMensual ?? 0 + return round2(plan.pagos.reduce((acc, p) => acc + moraDePago(p, tasa, hoy), 0)) +} + +export interface CobranzaItem { + plan: PlanPago + pago: Pago + /** Días de atraso (0 si aún no vence) */ + atraso: number +} + +/** + * Lista plana de "cobranza del día" a través de todos los planes ACTIVOS: + * pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos + * `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a igual + * atraso, el vencimiento más próximo primero. + */ +export function cobranzaDelDia( + planes: PlanPago[], + hoy: Date = new Date(), + diasAnticipacion = 7, +): CobranzaItem[] { + const limite = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate() + diasAnticipacion) + const items: CobranzaItem[] = [] + for (const plan of planes) { + if (statusPlan(plan) !== 'ACTIVO') continue + for (const pago of plan.pagos) { + if (estaCubierto(pago)) continue + if (compararFechas(parseFechaLocal(pago.fechaVencimiento), limite) > 0) continue + const atraso = Math.max( + 0, + Math.round(compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) / 86400000), + ) + items.push({ plan, pago, atraso }) + } + } + return items.sort((a, b) => { + if (a.atraso !== b.atraso) return b.atraso - a.atraso + return compararFechas( + parseFechaLocal(a.pago.fechaVencimiento), + parseFechaLocal(b.pago.fechaVencimiento), + ) + }) +} + +/** + * Valida el texto de una nota y devuelve la nota construida. + * El texto es requerido (mín. 3 caracteres). + */ +export function construirNota(texto: string): NotaPlan { + const limpio = texto.trim() + if (limpio.length < 3) { + throw new Error('La nota debe tener al menos 3 caracteres') + } + return { id: randomUUID(), fecha: new Date().toISOString(), texto: limpio } +} + +export interface AbonoInput { + pagoId: string + fechaPago: string // ISO + monto: number + referencia: string +} + +/** + * Valida un abono contra el plan y devuelve la aplicación construida más el + * pago objetivo. Reglas (idénticas al frontend): + * - rechaza montos <= 0 + * - el pago debe existir en el plan + * - estricto al centavo: el abono nunca puede exceder el restante del pago + */ +export function validarAbono(plan: PlanPago, abono: AbonoInput): { pago: Pago; aplicacion: AplicacionPago } { + if (round2(abono.monto) <= 0) { + throw new Error('El abono debe ser mayor a 0') + } + const pago = plan.pagos.find((p) => p.id === abono.pagoId) + if (!pago) throw new Error('Pago no encontrado en el plan') + const falta = restante(pago) + if (round2(abono.monto - falta) > 0) { + throw new Error(`El abono excede el restante del pago (${falta.toFixed(2)})`) + } + const aplicacion: AplicacionPago = { + id: randomUUID(), + fechaPago: abono.fechaPago, + monto: round2(abono.monto), + referencia: abono.referencia, + } + return { pago, aplicacion } +} + +/** Restante de un pago (expuesto para los controllers) */ +export function restantePago(p: Pago): number { + return restante(p) +} diff --git a/api-gateway/src/services/cotizacion.service.test.ts b/api-gateway/src/services/cotizacion.service.test.ts new file mode 100644 index 0000000..c681449 --- /dev/null +++ b/api-gateway/src/services/cotizacion.service.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from 'vitest' +import { + APARTADO_DEFAULT, + calcularNumMensualidades, + calcularPropuesta, + calcularPropuestas, + indiceMenorTotal, + obtenerPrecioLista, + totalPagado, + type UnidadCotizable, +} from './cotizacion.service.js' + +// Fixture validado contra el Excel real: U-101, con acabados, apartado 20000, N = 14 +const U101: UnidadCotizable = { + codigo: 'U-101', + modelo: '1R', + fechaEntrega: '2027-10-08', + supEstacionamiento: 12.5, + supConstruccion: 69.485, + supExcedente: 0, + precioSinAcabados: 4415000, + precioConAcabados: 4579000, + status: 'DISPONIBLE', + desarrollo: 'SENDA', +} +const N = 14 + +function calcular(descuentos = { creditoBancario: 0, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 }) { + return calcularPropuestas({ + unidad: U101, + acabados: true, + apartado: APARTADO_DEFAULT, + descuentos, + numMeses: N, + }) +} + +describe('fixture U-101 (validado contra Excel de producción)', () => { + it('precio de lista con acabados', () => { + expect(obtenerPrecioLista(U101, true)).toBe(4579000) + expect(obtenerPrecioLista(U101, false)).toBe(4415000) + }) + + it('Propuesta 1 — CRÉDITO BANCARIO (desc 0)', () => { + const [p] = calcular() + expect(p?.enganche).toBeCloseTo(94475, 2) + expect(p?.mensualidadesTotal).toBeCloseTo(343425, 2) + expect(p?.mensualidad).toBeCloseTo(24530.357142, 4) + expect(p?.pagoFinal).toBeCloseTo(4121100, 2) + }) + + it('Propuesta 2 — CRÉDITO INFONAVIT-COFINAVIT (desc 0)', () => { + const [, p] = calcular() + expect(p?.enganche).toBeCloseTo(94475, 2) + expect(p?.mensualidadesTotal).toBeCloseTo(114475, 2) + expect(p?.mensualidad).toBeCloseTo(8176.785714, 4) + expect(p?.pagoFinal).toBeCloseTo(4350050, 2) + }) + + it('Propuesta 3 — CONTADO (desc 2%)', () => { + const [, , p] = calcular() + expect(p?.montoDescuento).toBeCloseTo(91580, 2) + expect(p?.precioFinal).toBeCloseTo(4487420, 2) + expect(p?.enganche).toBeCloseTo(4018678, 2) + expect(p?.pagoFinal).toBeCloseTo(448742, 2) + expect(p?.mensualidad).toBeNull() + expect(p?.mensualidadesTotal).toBeNull() + }) + + it('Propuesta 4 — SEMI CONTADO (desc 1%)', () => { + const [, , , p] = calcular() + expect(p?.montoDescuento).toBeCloseTo(45790, 2) + expect(p?.precioFinal).toBeCloseTo(4533210, 2) + expect(p?.enganche).toBeCloseTo(2246605, 2) + expect(p?.mensualidadesTotal).toBeCloseTo(1813284, 2) + expect(p?.mensualidad).toBeCloseTo(129520.285714, 4) + expect(p?.pagoFinal).toBeCloseTo(453321, 2) + }) + + it('calcularPropuesta (una sola) reproduce CONTADO con descuento default', () => { + const p = calcularPropuesta(U101, 'CONTADO', true, APARTADO_DEFAULT, 0.02) + expect(p.montoDescuento).toBeCloseTo(91580, 2) + expect(p.precioFinal).toBeCloseTo(4487420, 2) + }) +}) + +describe('comportamiento documentado del Excel', () => { + it('Bancario con descuento > 0: el enganche sigue calculándose sobre precio LISTA', () => { + const [p] = calcular({ creditoBancario: 0.05, creditoInfonavit: 0, contado: 0.02, semiContado: 0.01 }) + expect(p?.enganche).toBeCloseTo(0.025 * 4579000 - APARTADO_DEFAULT, 2) + expect(p?.montoDescuento).toBeCloseTo(228950, 2) + expect(p?.precioFinal).toBeCloseTo(4350050, 2) + expect(p?.pagoFinal).toBeCloseTo(0.9 * 4350050, 2) + const total = 4350050 - APARTADO_DEFAULT - 94475 - 0.9 * 4350050 + expect(p?.mensualidadesTotal).toBeCloseTo(total, 2) + }) + + it('Infonavit usa precioLista (no precioFinal) en el total de mensualidades', () => { + const [, p] = calcular({ creditoBancario: 0, creditoInfonavit: 0.05, contado: 0.02, semiContado: 0.01 }) + const total = 4579000 - (p?.pagoFinal ?? 0) - APARTADO_DEFAULT - (p?.enganche ?? 0) + expect(p?.mensualidadesTotal).toBeCloseTo(total, 2) + }) +}) + +describe('calcularNumMensualidades', () => { + it('calcula meses redondeados con 30.4 días por mes', () => { + const hoy = new Date('2026-08-20T00:00:00') + expect(calcularNumMensualidades('2027-10-08', hoy)).toBe(14) + }) + + it('nunca devuelve menos de 1', () => { + const hoy = new Date('2030-01-01T00:00:00') + expect(calcularNumMensualidades('2027-10-08', hoy)).toBe(1) + }) +}) + +describe('totalPagado', () => { + it('bancario: coincide con el precio de lista (desc 0)', () => { + const [bancario] = calcular() + expect(totalPagado(bancario!, APARTADO_DEFAULT)).toBeCloseTo(4579000, 2) + }) + + it('contado y semi contado: coinciden con el precio final c/desc', () => { + const [, , contado, semi] = calcular() + expect(totalPagado(contado!, APARTADO_DEFAULT)).toBeCloseTo(4487420, 2) + expect(totalPagado(semi!, APARTADO_DEFAULT)).toBeCloseTo(4533210, 2) + }) +}) + +describe('indiceMenorTotal', () => { + it('contado (2% desc) tiene el menor total pagado', () => { + const propuestas = calcular() + const totales = propuestas.map((p) => totalPagado(p, APARTADO_DEFAULT)) + expect(indiceMenorTotal(totales)).toBe(2) + }) + + it('en empate gana el primero; lista vacía devuelve -1', () => { + expect(indiceMenorTotal([100, 100, 200])).toBe(0) + expect(indiceMenorTotal([])).toBe(-1) + }) +}) diff --git a/api-gateway/src/services/cotizacion.service.ts b/api-gateway/src/services/cotizacion.service.ts new file mode 100644 index 0000000..8939533 --- /dev/null +++ b/api-gateway/src/services/cotizacion.service.ts @@ -0,0 +1,245 @@ +/** + * Cálculos replicados fielmente del Excel de producción "COTIZADOR" de + * Frasa Desarrollos (port de apps/cotizador/src/lib/calculations.ts). + * NO "corregir" las fórmulas: algunas usan precio de lista donde + * intuitivamente iría precio final; así está en el Excel y así debe quedar. + */ + +export interface UnidadCotizable { + codigo: string + modelo: string + fechaEntrega: string // ISO 'YYYY-MM-DD' (terminación de obra) + supEstacionamiento: number + supConstruccion: number + supExcedente: number + precioSinAcabados: number + precioConAcabados: number + status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA' + desarrollo: string +} + +export type TipoPropuesta = + | 'CREDITO_BANCARIO' + | 'CREDITO_INFONAVIT_COFINAVIT' + | 'CONTADO' + | 'SEMI_CONTADO' + +export interface DescuentosPropuestas { + creditoBancario: number + creditoInfonavit: number + contado: number + semiContado: number +} + +export const APARTADO_DEFAULT = 20000 + +// Descuentos por defecto de cada propuesta (fracción, no porcentaje) +export const DESCUENTOS_DEFAULT: DescuentosPropuestas = { + creditoBancario: 0, + creditoInfonavit: 0, + contado: 0.02, + semiContado: 0.01, +} + +export interface PropuestaResultado { + tipo: TipoPropuesta + descuento: number // fracción (0.02 = 2%) + montoDescuento: number + precioFinal: number // precio lista - descuento + enganche: number // NETO del apartado (el apartado ya se pagó) + numMensualidades: number + mensualidad: number | null // null => N/A (Contado) + mensualidadesTotal: number | null // null => N/A (Contado) + pagoFinal: number +} + +export interface CotizacionInput { + unidad: UnidadCotizable + acabados: boolean // true => incluye cocina y closet + apartado: number + descuentos: DescuentosPropuestas + /** Permite forzar N en tests; por defecto se calcula desde fechaEntrega */ + numMeses?: number +} + +const MS_POR_MES = 30.4 * 86400000 + +/** Meses (redondeados, mínimo 1) desde hoy hasta la terminación de obra */ +export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new Date()): number { + const entrega = new Date(`${fechaEntrega}T00:00:00`) + return Math.max(1, Math.round((entrega.getTime() - hoy.getTime()) / MS_POR_MES)) +} + +function baseComun(precioLista: number, descuento: number) { + // Excel COTIZADOR: MONTO DE DESCUENTO y PRECIO FINAL C/DESC + const montoDescuento = precioLista * descuento + const precioFinal = precioLista - montoDescuento + return { montoDescuento, precioFinal } +} + +/** PROPUESTA 1 — CRÉDITO BANCARIO */ +function calcularBancario(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado { + const { montoDescuento, precioFinal } = baseComun(precioLista, descuento) + // Excel COTIZADOR!M17 — enganche 2.5% sobre precio LISTA, neto del apartado + const enganche = 0.025 * precioLista - apartado + // Excel COTIZADOR!M19 — pago final 90% del precio final c/desc + const pagoFinal = 0.9 * precioFinal + // Excel COTIZADOR!M18 — remanente a mensualidades + const mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal + return { + tipo: 'CREDITO_BANCARIO', + descuento, + montoDescuento, + precioFinal, + enganche, + numMensualidades: n, + mensualidad: mensualidadesTotal / n, + mensualidadesTotal, + pagoFinal, + } +} + +/** PROPUESTA 2 — CRÉDITO INFONAVIT-COFINAVIT */ +function calcularInfonavit(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado { + const { montoDescuento, precioFinal } = baseComun(precioLista, descuento) + // Excel COTIZADOR!M26 — enganche 2.5% sobre precio LISTA, neto del apartado + const enganche = 0.025 * precioLista - apartado + // Excel COTIZADOR!M28 — pago final 95% del precio final c/desc + const pagoFinal = 0.95 * precioFinal + // Excel COTIZADOR!M27 — OJO: el Excel usa precioLista aquí, NO precioFinal. Replicar tal cual. + const mensualidadesTotal = precioLista - pagoFinal - apartado - enganche + return { + tipo: 'CREDITO_INFONAVIT_COFINAVIT', + descuento, + montoDescuento, + precioFinal, + enganche, + numMensualidades: n, + mensualidad: mensualidadesTotal / n, + mensualidadesTotal, + pagoFinal, + } +} + +/** PROPUESTA 3 — CONTADO (sin mensualidades) */ +function calcularContado(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado { + const { montoDescuento, precioFinal } = baseComun(precioLista, descuento) + // Excel COTIZADOR!M36 — enganche 90% del precio final c/desc, neto del apartado + const enganche = 0.9 * precioFinal - apartado + // Excel COTIZADOR!M37 — pago final 10% del precio final c/desc + const pagoFinal = 0.1 * precioFinal + return { + tipo: 'CONTADO', + descuento, + montoDescuento, + precioFinal, + enganche, + numMensualidades: n, + mensualidad: null, // N/A + mensualidadesTotal: null, // N/A + pagoFinal, + } +} + +/** PROPUESTA 4 — SEMI CONTADO */ +function calcularSemiContado(precioLista: number, apartado: number, descuento: number, n: number): PropuestaResultado { + const { montoDescuento, precioFinal } = baseComun(precioLista, descuento) + // Excel COTIZADOR!M45 — enganche 50% del precio final c/desc, neto del apartado + const enganche = 0.5 * precioFinal - apartado + // Excel COTIZADOR!M46 — 40% del precio final a mensualidades (= 1 - 0.5 - 0.1) + const mensualidadesTotal = precioFinal * 0.4 + // Excel COTIZADOR!M47 — pago final 10% del precio final c/desc + const pagoFinal = 0.1 * precioFinal + return { + tipo: 'SEMI_CONTADO', + descuento, + montoDescuento, + precioFinal, + enganche, + numMensualidades: n, + mensualidad: mensualidadesTotal / n, + mensualidadesTotal, + pagoFinal, + } +} + +/** Precio de lista según si incluye cocina y closet (acabados) */ +export function obtenerPrecioLista(unidad: UnidadCotizable, acabados: boolean): number { + return acabados ? unidad.precioConAcabados : unidad.precioSinAcabados +} + +/** + * Total pagado por el cliente en una propuesta: + * apartado + enganche (neto del apartado) + mensualidades + pago final. + */ +export function totalPagado(p: PropuestaResultado, apartado: number): number { + return apartado + p.enganche + (p.mensualidadesTotal ?? 0) + p.pagoFinal +} + +/** Índice del menor total pagado; en empate gana el primero. -1 si la lista está vacía */ +export function indiceMenorTotal(totales: number[]): number { + if (totales.length === 0) return -1 + let indice = 0 + for (let i = 1; i < totales.length; i++) { + const actual = totales[i] + const mejor = totales[indice] + if (actual !== undefined && mejor !== undefined && actual < mejor) indice = i + } + return indice +} + +/** Calcula las 4 propuestas comerciales */ +export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] { + const { unidad, acabados, apartado, descuentos } = input + const precioLista = obtenerPrecioLista(unidad, acabados) + const n = input.numMeses ?? calcularNumMensualidades(unidad.fechaEntrega) + return [ + calcularBancario(precioLista, apartado, descuentos.creditoBancario, n), + calcularInfonavit(precioLista, apartado, descuentos.creditoInfonavit, n), + calcularContado(precioLista, apartado, descuentos.contado, n), + calcularSemiContado(precioLista, apartado, descuentos.semiContado, n), + ] +} + +/** Mapa entre el nombre del API (body) y el nombre interno de la propuesta */ +export const TIPO_PROPUESTA_API = { + BANCARIO: 'CREDITO_BANCARIO', + INFONAVIT: 'CREDITO_INFONAVIT_COFINAVIT', + CONTADO: 'CONTADO', + SEMI_CONTADO: 'SEMI_CONTADO', +} as const + +export type TipoPropuestaApi = keyof typeof TIPO_PROPUESTA_API + +const DESCUENTO_DEFAULT_POR_TIPO: Record = { + BANCARIO: DESCUENTOS_DEFAULT.creditoBancario, + INFONAVIT: DESCUENTOS_DEFAULT.creditoInfonavit, + CONTADO: DESCUENTOS_DEFAULT.contado, + SEMI_CONTADO: DESCUENTOS_DEFAULT.semiContado, +} + +/** + * Calcula UNA propuesta (la elegida por el cliente) para persistir/cotizar. + * `descuento` opcional (fracción); si no viene, usa el default del tipo. + */ +export function calcularPropuesta( + unidad: UnidadCotizable, + tipo: TipoPropuestaApi, + acabados: boolean, + apartado: number = APARTADO_DEFAULT, + descuento?: number, +): PropuestaResultado { + const precioLista = obtenerPrecioLista(unidad, acabados) + const n = calcularNumMensualidades(unidad.fechaEntrega) + const d = descuento ?? DESCUENTO_DEFAULT_POR_TIPO[tipo] + switch (tipo) { + case 'BANCARIO': + return calcularBancario(precioLista, apartado, d, n) + case 'INFONAVIT': + return calcularInfonavit(precioLista, apartado, d, n) + case 'CONTADO': + return calcularContado(precioLista, apartado, d, n) + case 'SEMI_CONTADO': + return calcularSemiContado(precioLista, apartado, d, n) + } +} diff --git a/api-gateway/src/services/monday.service.test.ts b/api-gateway/src/services/monday.service.test.ts new file mode 100644 index 0000000..a26e03e --- /dev/null +++ b/api-gateway/src/services/monday.service.test.ts @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MondayService } from './monday.service.js' + +// Columnas con slugs autogenerados (como las reales de Monday); el servicio +// debe resolverlas por TÍTULO, nunca hardcodear el id. +const COLUMNAS_UNIDADES = [ + { id: 'text_mm6eaaa', title: 'Desarrollo', type: 'text' }, + { id: 'text_mm6ebbb', title: 'Modelo', type: 'text' }, + { id: 'date_mm6eccc', title: 'Fecha entrega', type: 'date' }, + { id: 'numeric_mm6eddd', title: 'Sup. construcción', type: 'numbers' }, + { id: 'numeric_mm6eeee', title: 'Sup. excedente', type: 'numbers' }, + { id: 'numeric_mm6efff', title: 'Sup. estacionamiento', type: 'numbers' }, + { id: 'numeric_mm6eggg', title: 'Precio sin acabados', type: 'numbers' }, + { id: 'numeric_mm6ehhh', title: 'Precio con acabados', type: 'numbers' }, + { id: 'color_mm6eiii', title: 'Disponibilidad', type: 'status' }, +] + +function itemUnidad(id: string, codigo: string, disponibilidad: string) { + return { + id, + name: codigo, + column_values: [ + { id: 'text_mm6eaaa', text: 'SENDA', value: null }, + { id: 'text_mm6ebbb', text: '1R', value: null }, + { id: 'date_mm6eccc', text: '2027-10-08', value: null }, + { id: 'numeric_mm6eddd', text: '69.485', value: null }, + { id: 'numeric_mm6eeee', text: '0', value: null }, + { id: 'numeric_mm6efff', text: '12.5', value: null }, + { id: 'numeric_mm6eggg', text: '4415000', value: null }, + { id: 'numeric_mm6ehhh', text: '4579000', value: null }, + { id: 'color_mm6eiii', text: disponibilidad, value: null }, + ], + } +} + +const respuestaOk = (data: unknown): Response => + new Response(JSON.stringify({ data }), { status: 200, headers: { 'Content-Type': 'application/json' } }) + +interface LlamadaFetch { + body: { query: string; variables: Record } +} + +/** Instala un fetch mockeado que responde según el query; devuelve las llamadas registradas */ +function mockearFetch(handler: (body: LlamadaFetch['body']) => Response): LlamadaFetch[] { + const llamadas: LlamadaFetch[] = [] + vi.stubGlobal('fetch', async (_url: unknown, init: { body: string }) => { + const body = JSON.parse(init.body) as LlamadaFetch['body'] + llamadas.push({ body }) + return handler(body) + }) + return llamadas +} + +const crearServicio = (): MondayService => + new MondayService({ apiKey: 'test', apiUrl: 'https://api.test/v2', baseDelayMs: 1 }) + +describe('MondayService', () => { + beforeEach(() => vi.useRealTimers()) + afterEach(() => vi.unstubAllGlobals()) + + it('resuelve column IDs por TÍTULO y mapea unidades al tipo del cotizador', async () => { + mockearFetch((body) => { + if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] }) + return respuestaOk({ + boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }], + }) + }) + + const unidades = await crearServicio().getUnidades() + expect(unidades).toHaveLength(1) + expect(unidades[0]).toEqual({ + codigo: 'U-101', + modelo: '1R', + fechaEntrega: '2027-10-08', + supConstruccion: 69.485, + supExcedente: 0, + supEstacionamiento: 12.5, + precioSinAcabados: 4415000, + precioConAcabados: 4579000, + status: 'DISPONIBLE', + desarrollo: 'SENDA', + }) + }) + + it('excluye items placeholder sin datos (p. ej. "Task 1" default de Monday)', async () => { + const placeholder = { + id: '99', + name: 'Task 1', + column_values: COLUMNAS_UNIDADES.map((c) => ({ id: c.id, text: '', value: null })), + } + mockearFetch((body) => { + if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] }) + return respuestaOk({ + boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE'), placeholder] } }], + }) + }) + + const unidades = await crearServicio().getUnidades() + expect(unidades.map((u) => u.codigo)).toEqual(['U-101']) + }) + + it('pagina con cursor hasta agotar las páginas', async () => { + const llamadas = mockearFetch((body) => { + if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] }) + if (body.variables['cursor'] === 'pagina-2') { + return respuestaOk({ + boards: [{ items_page: { cursor: null, items: [itemUnidad('2', 'U-102', 'APARTADA')] } }], + }) + } + return respuestaOk({ + boards: [{ items_page: { cursor: 'pagina-2', items: [itemUnidad('1', 'U-101', 'VENDIDA')] } }], + }) + }) + + const unidades = await crearServicio().getUnidades() + expect(unidades.map((u) => u.codigo)).toEqual(['U-101', 'U-102']) + expect(unidades[0]?.status).toBe('VENDIDA') + expect(unidades[1]?.status).toBe('APARTADA') + // 1 llamada de columnas + 2 de items_page + expect(llamadas).toHaveLength(3) + expect(llamadas[2]?.body.variables['cursor']).toBe('pagina-2') + }) + + it('usa el cache de 5 minutos para no repetir llamadas', async () => { + const llamadas = mockearFetch((body) => { + if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] }) + return respuestaOk({ + boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }], + }) + }) + const svc = crearServicio() + await svc.getUnidades() + await svc.getUnidades() + expect(llamadas).toHaveLength(2) // columnas + una sola página de items + }) + + it('reintenta ante HTTP 429 con backoff y logra responder', async () => { + let intentos = 0 + mockearFetch((body) => { + if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] }) + intentos++ + if (intentos === 1) return new Response('rate limited', { status: 429 }) + return respuestaOk({ + boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }], + }) + }) + + const unidades = await crearServicio().getUnidades() + expect(unidades).toHaveLength(1) + expect(intentos).toBe(2) + }) + + it('reintenta ante error de complejidad (error_code COMPLEXITY_BUDGET_EXCEEDED)', async () => { + let intentos = 0 + mockearFetch((body) => { + if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: COLUMNAS_UNIDADES }] }) + intentos++ + if (intentos < 3) { + return new Response( + JSON.stringify({ error_code: 'COMPLEXITY_BUDGET_EXCEEDED', error_message: 'Complexity budget exceeded' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + } + return respuestaOk({ + boards: [{ items_page: { cursor: null, items: [itemUnidad('1', 'U-101', 'DISPONIBLE')] } }], + }) + }) + + const unidades = await crearServicio().getUnidades() + expect(unidades).toHaveLength(1) + expect(intentos).toBe(3) + }) + + it('agota los reintentos y lanza error', async () => { + mockearFetch(() => new Response('rate limited', { status: 429 })) + await expect(crearServicio().getUnidades()).rejects.toThrow(/no respondió/) + }) + + it('lanza en errores GraphQL no retryables sin reintentar', async () => { + let llamadasTotal = 0 + mockearFetch(() => { + llamadasTotal++ + return new Response(JSON.stringify({ errors: [{ message: 'Invalid board ID' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) + await expect(crearServicio().getUnidades()).rejects.toThrow(/Invalid board ID/) + expect(llamadasTotal).toBe(1) + }) + + it('crearOportunidad envía column_values como JSON.stringify en variables', async () => { + const columnasOportunidades = [ + { id: 'text_u1', title: 'Unidad', type: 'text' }, + { id: 'text_e1', title: 'Cliente email', type: 'text' }, + { id: 'text_t1', title: 'Cliente teléfono', type: 'text' }, + { id: 'color_tp', title: 'Tipo propuesta', type: 'status' }, + { id: 'numeric_pl', title: 'Precio de lista', type: 'numbers' }, + { id: 'numeric_dp', title: 'Descuento %', type: 'numbers' }, + { id: 'numeric_pf', title: 'Precio final', type: 'numbers' }, + { id: 'date_fc', title: 'Fecha cotización', type: 'date' }, + { id: 'color_es', title: 'Estado', type: 'status' }, + ] + const llamadas = mockearFetch((body) => { + if (body.query.includes('columns')) return respuestaOk({ boards: [{ columns: columnasOportunidades }] }) + return respuestaOk({ create_item: { id: '987654321' } }) + }) + + const id = await crearServicio().crearOportunidad({ + clienteNombre: 'Juan Pérez', + unidadCodigo: 'U-101', + tipoPropuestaLabel: 'BANCARIO', + precioLista: 4579000, + descuentoPct: 0, + precioFinal: 4579000, + fechaCotizacion: '2026-08-21', + estadoLabel: 'COTIZADA', + clienteEmail: 'juan@example.com', + }) + expect(id).toBe('987654321') + + const mutacion = llamadas.find((l) => l.body.query.includes('create_item')) + expect(mutacion).toBeDefined() + const columnValues = mutacion?.body.variables['columnValues'] + expect(typeof columnValues).toBe('string') // JSON.stringify, nunca interpolado en el query + const parsed = JSON.parse(columnValues as string) as Record + expect(parsed['text_u1']).toBe('U-101') + expect(parsed['text_e1']).toBe('juan@example.com') + expect(parsed['numeric_pl']).toBe(4579000) + expect(parsed['color_tp']).toEqual({ label: 'BANCARIO' }) + expect(parsed['color_es']).toEqual({ label: 'COTIZADA' }) + expect(parsed['date_fc']).toEqual({ date: '2026-08-21' }) + expect(parsed['text_t1']).toBeUndefined() // no se envían columnas vacías + }) +}) diff --git a/api-gateway/src/services/monday.service.ts b/api-gateway/src/services/monday.service.ts new file mode 100644 index 0000000..d1030f8 --- /dev/null +++ b/api-gateway/src/services/monday.service.ts @@ -0,0 +1,391 @@ +import { config } from '../config/index.js' +import type { UnidadCotizable } from './cotizacion.service.js' +import { AppError } from '../utils/errors.js' + +/** Unidad tal como la consume el API (y el frontend del cotizador) */ +export type Unidad = UnidadCotizable + +interface ColumnaMeta { + id: string + title: string + type: string +} + +interface ColumnValue { + id: string + text: string | null + value: string | null +} + +interface ItemMonday { + id: string + name: string + column_values: ColumnValue[] +} + +interface GraphQLErrorItem { + message: string + extensions?: { code?: string } +} + +interface GraphQLResponse { + data?: T + errors?: GraphQLErrorItem[] + error_code?: string + error_message?: string +} + +/** Error retryable de Monday (rate limit / presupuesto de complejidad / 5xx) */ +class MondayRetryableError extends Error {} + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + +const CACHE_UNIDADES_MS = 5 * 60 * 1000 // 5 minutos + +export interface MondayServiceOptions { + apiKey?: string + apiUrl?: string + maxReintentos?: number + baseDelayMs?: number +} + +export class MondayService { + private readonly apiKey: string + private readonly apiUrl: string + private readonly maxReintentos: number + private readonly baseDelayMs: number + + // Cache de metadatos de columnas: boardId -> (título -> meta) + private readonly columnasCache = new Map>() + // Cache de unidades + mapa codigo -> itemId (para escrituras) + private unidadesCache: { unidades: Unidad[]; itemIds: Map; expira: number } | null = null + + constructor(opts: MondayServiceOptions = {}) { + this.apiKey = opts.apiKey ?? config.monday.apiKey + this.apiUrl = opts.apiUrl ?? config.monday.apiUrl + this.maxReintentos = opts.maxReintentos ?? 3 + this.baseDelayMs = opts.baseDelayMs ?? 500 + } + + /** + * Helper GraphQL contra la API de Monday. + * - Headers Authorization / API-Version. + * - Lanza en errores GraphQL. + * - Retry con backoff exponencial ante 429, errores de complejidad y 5xx + * (máx `maxReintentos` reintentos). + */ + async graphql(query: string, variables: Record = {}): Promise { + let ultimoError: Error | null = null + for (let intento = 0; intento <= this.maxReintentos; intento++) { + if (intento > 0) await sleep(this.baseDelayMs * 2 ** (intento - 1)) + try { + return await this.graphqlOnce(query, variables) + } catch (err) { + if (!(err instanceof MondayRetryableError)) throw err + ultimoError = err + } + } + throw new AppError(502, `Monday no respondió tras ${this.maxReintentos + 1} intentos: ${ultimoError?.message}`) + } + + private async graphqlOnce(query: string, variables: Record): Promise { + const res = await fetch(this.apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: this.apiKey, + 'API-Version': config.monday.apiVersion, + }, + body: JSON.stringify({ query, variables }), + }) + + if (res.status === 429) throw new MondayRetryableError('HTTP 429 (rate limit)') + if (res.status >= 500) throw new MondayRetryableError(`HTTP ${res.status}`) + if (!res.ok) throw new AppError(502, `Monday respondió HTTP ${res.status}`) + + const body = (await res.json()) as GraphQLResponse + + // Monday devuelve errores de complejidad como 200 con error_code + if (body.error_code) { + if (/complexity/i.test(body.error_code) || /complexity/i.test(body.error_message ?? '')) { + throw new MondayRetryableError(`Complejidad excedida: ${body.error_code}`) + } + throw new AppError(502, `Error de Monday: ${body.error_code} ${body.error_message ?? ''}`) + } + if (body.errors?.length) { + const msg = body.errors.map((e) => e.message).join('; ') + const esComplejidad = body.errors.some( + (e) => /complexity/i.test(e.message) || /complexity/i.test(e.extensions?.code ?? ''), + ) + if (esComplejidad) throw new MondayRetryableError(`Complejidad excedida: ${msg}`) + throw new AppError(502, `Errores GraphQL de Monday: ${msg}`) + } + if (!body.data) throw new AppError(502, 'Monday respondió sin data') + return body.data + } + + /** Metadatos de columnas de un tablero, mapeadas por TÍTULO (cache en memoria) */ + private async columnasDe(boardId: string): Promise> { + const cached = this.columnasCache.get(boardId) + if (cached) return cached + const data = await this.graphql<{ boards: { columns: ColumnaMeta[] }[] }>( + `query ($ids: [ID!]) { boards(ids: $ids) { columns { id title type } } }`, + { ids: [boardId] }, + ) + const columnas = data.boards[0]?.columns ?? [] + const mapa = new Map(columnas.map((c) => [c.title, c])) + this.columnasCache.set(boardId, mapa) + return mapa + } + + /** Resuelve el column ID por título exacto; lanza si no existe en el tablero */ + private async columnaId(boardId: string, titulo: string): Promise { + const cols = await this.columnasDe(boardId) + const col = cols.get(titulo) + if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero ${boardId}`) + return col + } + + /** Ping barato para /api/health */ + async ping(): Promise { + try { + const data = await this.graphql<{ boards: { id: string }[] }>( + `query ($ids: [ID!]) { boards(ids: $ids) { id } }`, + { ids: [config.monday.boards.unidades] }, + ) + return (data.boards?.length ?? 0) > 0 + } catch { + return false + } + } + + /** Invalida el cache de unidades (llamar tras cualquier escritura que las afecte) */ + invalidarCacheUnidades(): void { + this.unidadesCache = null + } + + /** Todas las unidades del tablero, con paginación por cursor y cache de 5 min */ + async getUnidades(): Promise { + const ahora = Date.now() + if (this.unidadesCache && this.unidadesCache.expira > ahora) { + return this.unidadesCache.unidades + } + const boardId = config.monday.boards.unidades + const cols = await this.columnasDe(boardId) + + const items: ItemMonday[] = [] + let cursor: string | null = null + do { + // La primera página no lleva cursor; las siguientes sí (nunca asumir una sola página) + const data: { boards: { items_page: { cursor: string | null; items: ItemMonday[] } }[] } = + cursor === null + ? await this.graphql( + `query ($ids: [ID!]) { + boards(ids: $ids) { + items_page(limit: 500) { cursor items { id name column_values { id text value } } } + } + }`, + { ids: [boardId] }, + ) + : await this.graphql( + `query ($ids: [ID!], $cursor: String!) { + boards(ids: $ids) { + items_page(limit: 500, cursor: $cursor) { cursor items { id name column_values { id text value } } } + } + }`, + { ids: [boardId], cursor }, + ) + const page = data.boards[0]?.items_page + if (!page) break + items.push(...page.items) + cursor = page.cursor + } while (cursor !== null) + + const itemIds = new Map() + const unidades: Unidad[] = [] + for (const item of items) { + const unidad = this.mapearUnidad(item, cols) + // Items placeholder (sin modelo ni precios, p. ej. el "Task 1" default de + // Monday) no son inventario: se excluyen del listado + if (unidad.modelo === '' && unidad.precioSinAcabados === 0 && unidad.precioConAcabados === 0) continue + itemIds.set(item.name, item.id) + unidades.push(unidad) + } + this.unidadesCache = { unidades, itemIds, expira: ahora + CACHE_UNIDADES_MS } + return unidades + } + + /** Una unidad por código (U-101, U-310...); null si no existe */ + async getUnidad(codigo: string): Promise { + const unidades = await this.getUnidades() + return unidades.find((u) => u.codigo === codigo) ?? null + } + + private mapearUnidad(item: ItemMonday, cols: Map): Unidad { + const porId = new Map(item.column_values.map((cv) => [cv.id, cv])) + const textoDe = (titulo: string): string => { + const col = cols.get(titulo) + if (!col) throw new AppError(500, `Columna "${titulo}" no existe en el tablero Unidades`) + return porId.get(col.id)?.text ?? '' + } + const numeroDe = (titulo: string): number => { + const n = Number(textoDe(titulo)) + return Number.isFinite(n) ? n : 0 + } + const status = textoDe('Disponibilidad').toUpperCase() + return { + codigo: item.name, + modelo: textoDe('Modelo'), + fechaEntrega: textoDe('Fecha entrega'), + supEstacionamiento: numeroDe('Sup. estacionamiento'), + supConstruccion: numeroDe('Sup. construcción'), + supExcedente: numeroDe('Sup. excedente'), + precioSinAcabados: numeroDe('Precio sin acabados'), + precioConAcabados: numeroDe('Precio con acabados'), + status: status === 'APARTADA' || status === 'VENDIDA' ? status : 'DISPONIBLE', + desarrollo: textoDe('Desarrollo'), + } + } + + /** + * Serializa un valor crudo al formato que espera el tipo de columna de Monday. + * column_values SIEMPRE se envía con JSON.stringify en variables (nunca + * template literals dentro del query). + */ + private valorPorTipo(col: ColumnaMeta, crudo: string | number): unknown { + switch (col.type) { + case 'status': + return { label: String(crudo) } + case 'date': + return { date: String(crudo) } + case 'numbers': + return typeof crudo === 'number' ? crudo : Number(crudo) + case 'email': + return { email: String(crudo), text: String(crudo) } + case 'phone': + return { phone: String(crudo), countryShortName: 'MX' } + default: + return String(crudo) + } + } + + private async construirColumnValues( + boardId: string, + valores: Record, + ): Promise { + const out: Record = {} + for (const [titulo, crudo] of Object.entries(valores)) { + if (crudo === undefined || crudo === '') continue + const col = await this.columnaId(boardId, titulo) + out[col.id] = this.valorPorTipo(col, crudo) + } + return JSON.stringify(out) + } + + private async crearItem(boardId: string, nombre: string, columnValues: string): Promise { + const data = await this.graphql<{ create_item: { id: string } }>( + `mutation ($boardId: ID!, $itemName: String!, $columnValues: JSON!) { + create_item(board_id: $boardId, item_name: $itemName, column_values: $columnValues) { id } + }`, + { boardId, itemName: nombre, columnValues }, + ) + return data.create_item.id + } + + // ---------- Escrituras de negocio ---------- + + async crearOportunidad(datos: DatosOportunidad): Promise { + const boardId = config.monday.boards.oportunidades + const columnValues = await this.construirColumnValues(boardId, { + Unidad: datos.unidadCodigo, + 'Cliente email': datos.clienteEmail, + 'Cliente teléfono': datos.clienteTelefono, + 'Tipo propuesta': datos.tipoPropuestaLabel, + 'Precio de lista': datos.precioLista, + 'Descuento %': datos.descuentoPct, + 'Precio final': datos.precioFinal, + 'Fecha cotización': datos.fechaCotizacion, + Estado: datos.estadoLabel, + }) + return this.crearItem(boardId, datos.clienteNombre, columnValues) + } + + async crearContacto(datos: DatosContacto): Promise { + const boardId = config.monday.boards.contactos + const columnValues = await this.construirColumnValues(boardId, { + Teléfono: datos.telefono, + Email: datos.email, + CURP: datos.curp, + RFC: datos.rfc, + Dirección: datos.direccion, + Estado: datos.estadoLabel, + }) + return this.crearItem(boardId, datos.nombre, columnValues) + } + + async crearApartado(datos: DatosApartado): Promise { + const boardId = config.monday.boards.apartado + const columnValues = await this.construirColumnValues(boardId, { + Unidad: datos.unidadCodigo, + 'Monto apartado': datos.montoApartado, + 'Fecha apartado': datos.fechaApartado, + 'Fecha límite': datos.fechaLimite, + Estado: datos.estadoLabel, + }) + return this.crearItem(boardId, datos.clienteNombre, columnValues) + } + + async actualizarDisponibilidadUnidad(codigo: string, status: 'DISPONIBLE' | 'APARTADA' | 'VENDIDA'): Promise { + const boardId = config.monday.boards.unidades + // Asegura cache fresco para resolver el itemId + await this.getUnidades() + const itemId = this.unidadesCache?.itemIds.get(codigo) + if (!itemId) throw new AppError(404, `Unidad ${codigo} no encontrada en Monday`) + const col = await this.columnaId(boardId, 'Disponibilidad') + await this.graphql( + `mutation ($boardId: ID!, $itemId: ID!, $columnId: String!, $value: JSON!) { + change_column_value(board_id: $boardId, item_id: $itemId, column_id: $columnId, value: $value) { id } + }`, + { boardId, itemId, columnId: col.id, value: JSON.stringify({ label: status }) }, + ) + this.invalidarCacheUnidades() + } +} + +// ---------- DTOs de escritura ---------- + +export interface DatosOportunidad { + clienteNombre: string + unidadCodigo: string + tipoPropuestaLabel: string + precioLista: number + descuentoPct: number // porcentaje (2 = 2%) + precioFinal: number + fechaCotizacion: string // 'YYYY-MM-DD' + estadoLabel: string // p. ej. 'COTIZADA' + clienteEmail?: string + clienteTelefono?: string +} + +export interface DatosContacto { + nombre: string + telefono?: string + email?: string + curp?: string + rfc?: string + direccion?: string + estadoLabel: string // p. ej. 'CLIENTE' +} + +export interface DatosApartado { + clienteNombre: string + unidadCodigo: string + montoApartado?: number + fechaApartado: string // 'YYYY-MM-DD' + fechaLimite: string // 'YYYY-MM-DD' + estadoLabel: string // p. ej. 'VIGENTE' +} + +/** Singleton usado por los controllers (los tests instancian el suyo) */ +export const mondayService = new MondayService() + diff --git a/api-gateway/src/services/prisma.ts b/api-gateway/src/services/prisma.ts new file mode 100644 index 0000000..8ed331b --- /dev/null +++ b/api-gateway/src/services/prisma.ts @@ -0,0 +1,4 @@ +import { PrismaClient } from '@prisma/client' + +// Cliente único compartido (Prisma gestiona el pool internamente) +export const prisma = new PrismaClient() diff --git a/api-gateway/src/utils/errors.ts b/api-gateway/src/utils/errors.ts new file mode 100644 index 0000000..2062c14 --- /dev/null +++ b/api-gateway/src/utils/errors.ts @@ -0,0 +1,24 @@ +/** Error con HTTP status para que el error handler lo traduzca a respuesta */ +export class AppError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message) + this.name = 'AppError' + } +} + +export class NotFoundError extends AppError { + constructor(message = 'Recurso no encontrado') { + super(404, message) + this.name = 'NotFoundError' + } +} + +export class BadRequestError extends AppError { + constructor(message: string) { + super(400, message) + this.name = 'BadRequestError' + } +} diff --git a/api-gateway/tsconfig.json b/api-gateway/tsconfig.json new file mode 100644 index 0000000..1031b09 --- /dev/null +++ b/api-gateway/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "sourceMap": false, + "declaration": false + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "node_modules", "dist"] +} diff --git a/api-gateway/vitest.config.ts b/api-gateway/vitest.config.ts new file mode 100644 index 0000000..b22e14d --- /dev/null +++ b/api-gateway/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + env: { + // config/index.ts exige estas vars al importarse; los tests nunca llaman a Monday real + MONDAY_API_KEY: 'test-key', + MONDAY_BOARD_CONTACTOS: '111', + MONDAY_BOARD_DESARROLLOS: '222', + MONDAY_BOARD_UNIDADES: '333', + MONDAY_BOARD_LEADS: '444', + MONDAY_BOARD_OPORTUNIDADES: '555', + MONDAY_BOARD_APARTADO: '666', + MONDAY_BOARD_VENTAS: '777', + }, + }, +}) diff --git a/apps/alta-clientes/src/pages/AltaClientePage.tsx b/apps/alta-clientes/src/pages/AltaClientePage.tsx index 7ffb85c..e97869e 100644 --- a/apps/alta-clientes/src/pages/AltaClientePage.tsx +++ b/apps/alta-clientes/src/pages/AltaClientePage.tsx @@ -10,19 +10,25 @@ import DocumentosForm from '../forms/DocumentosForm' import FirmasForm from '../forms/FirmasForm' import { useAltaCliente, PASOS } from '../hooks/useAltaCliente' import { crearAltaCliente, subirDocumentos } from '../services/api' -import { enviarAMonday } from '../services/monday' export default function AltaClientePage() { const { paso, siguiente, anterior, irAPaso, datos, alta, actualizar, agregarDocumento, quitarDocumento } = useAltaCliente() const [registro, setRegistro] = useState('') + const [sinConexion, setSinConexion] = useState(false) - // Registra el alta (stub API + Monday) al llegar al paso de firmas + // Registra el alta en el API al llegar al paso de firmas; si el API no + // responde, el flujo continúa solo local (los PDFs se generan igual) const registrarAlta = async () => { - const { id } = await crearAltaCliente(alta) - await subirDocumentos(id, alta) - await enviarAMonday(alta) - setRegistro(id) + const creada = await crearAltaCliente(alta) + if (creada) { + await subirDocumentos(creada.id, alta) + setRegistro(creada.id) + setSinConexion(false) + } else { + setRegistro('') + setSinConexion(true) + } } return ( @@ -117,7 +123,15 @@ export default function AltaClientePage() { <> {registro && (

- Alta registrada (stub API): {registro} + Alta registrada en el servidor: {registro} +

+ )} + {sinConexion && ( +

+ Sin conexión al servidor — el expediente se generó solo localmente

)} Promise + +const docPrueba = (): AltaCliente['documentos'] => [ + { + slot: 'INE', + nombre: 'ine.pdf', + tamano: 4, + tipoMime: 'application/pdf', + archivo: new File(['pdf!'], 'ine.pdf', { type: 'application/pdf' }), + }, + { + slot: 'CURP', + nombre: 'curp.png', + tamano: 4, + tipoMime: 'image/png', + archivo: new File(['png!'], 'curp.png', { type: 'image/png' }), + }, +] + +const altaConDocumentos = (): AltaCliente => ({ ...fixtureAlta, documentos: docPrueba() }) + +describe('services/api', () => { + let fetchMock: ReturnType> + let warnSpy: ReturnType + + beforeEach(() => { + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.unstubAllGlobals() + warnSpy.mockRestore() + }) + + describe('crearAltaCliente', () => { + it('hace POST JSON a /clientes y devuelve el id del servidor', async () => { + fetchMock.mockResolvedValue( + new Response(JSON.stringify({ id: 'srv-123' }), { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }), + ) + + const alta = altaConDocumentos() + const resultado = await crearAltaCliente(alta) + + expect(resultado).toEqual({ id: 'srv-123' }) + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toBe('http://192.168.10.123:3000/api/clientes') + expect(init.method).toBe('POST') + expect(init.headers).toEqual({ 'Content-Type': 'application/json' }) + + // Los File no viajan en el JSON: solo metadatos; la firma va como string + const cuerpo = JSON.parse(String(init.body)) as Record + const documentos = cuerpo['documentos'] as Record[] + expect(documentos).toEqual([ + { tipo: 'INE', nombre: 'ine.pdf', tamano: 4, mime: 'application/pdf' }, + { tipo: 'CURP', nombre: 'curp.png', tamano: 4, mime: 'image/png' }, + ]) + expect(JSON.stringify(cuerpo)).not.toContain('archivo') + expect((cuerpo['firma'] as Record)['firmaDataUrl']).toBe( + fixtureAlta.firma.firmaDataUrl, + ) + }) + + it('ante un fallo de red devuelve null y avisa con console.warn', async () => { + fetchMock.mockRejectedValue(new TypeError('fetch failed')) + + const resultado = await crearAltaCliente(altaConDocumentos()) + + expect(resultado).toBeNull() + expect(warnSpy).toHaveBeenCalled() + }) + + it('ante una respuesta HTTP de error devuelve null y avisa', async () => { + fetchMock.mockResolvedValue(new Response('error', { status: 500 })) + + const resultado = await crearAltaCliente(altaConDocumentos()) + + expect(resultado).toBeNull() + expect(warnSpy).toHaveBeenCalled() + }) + }) + + describe('subirDocumentos', () => { + it('arma el multipart correcto: una petición por documento con tipo y archivos', async () => { + fetchMock.mockResolvedValue(new Response('[]', { status: 201 })) + + await subirDocumentos('srv-123', altaConDocumentos()) + + expect(fetchMock).toHaveBeenCalledTimes(2) + + const [url1, init1] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url1).toBe('http://192.168.10.123:3000/api/clientes/srv-123/documentos') + expect(init1.method).toBe('POST') + // Sin Content-Type manual: el navegador pone el boundary del multipart + expect(init1.headers).toBeUndefined() + + const form1 = init1.body as FormData + expect(form1).toBeInstanceOf(FormData) + expect(form1.get('tipo')).toBe('INE') + const archivo1 = form1.get('archivos') + expect(archivo1).toBeInstanceOf(File) + expect((archivo1 as File).name).toBe('ine.pdf') + expect((archivo1 as File).type).toBe('application/pdf') + + const [, init2] = fetchMock.mock.calls[1] as [string, RequestInit] + const form2 = init2.body as FormData + expect(form2.get('tipo')).toBe('CURP') + expect((form2.get('archivos') as File).name).toBe('curp.png') + }) + + it('no hace ninguna petición si no hay documentos', async () => { + await subirDocumentos('srv-123', fixtureAlta) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('ante un fallo de red avisa y continúa sin lanzar', async () => { + fetchMock.mockRejectedValue(new TypeError('fetch failed')) + + await expect(subirDocumentos('srv-123', altaConDocumentos())).resolves.toBeUndefined() + expect(warnSpy).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/apps/alta-clientes/src/services/api.ts b/apps/alta-clientes/src/services/api.ts index 22ddb7b..83af100 100644 --- a/apps/alta-clientes/src/services/api.ts +++ b/apps/alta-clientes/src/services/api.ts @@ -1,21 +1,71 @@ import type { AltaCliente } from '../types' -import { uuid } from '../lib/uuid' + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://192.168.10.123:3000/api' +// TODO produccion: '/api' same-origin detras de NGINX export interface AltaClienteCreada { id: string } -/** Registra el alta de cliente en el backend */ -export async function crearAltaCliente(payload: AltaCliente): Promise { - // TODO: POST /api/clientes - await new Promise((resolve) => setTimeout(resolve, 400)) - console.log('[api] crearAltaCliente (stub):', payload.datosGenerales.nombreCompleto) - return { id: uuid() } +interface DocumentoMeta { + tipo: string + nombre: string + tamano: number + mime: string } -/** Sube los documentos del expediente del cliente */ -export async function subirDocumentos(clienteId: string, payload: AltaCliente): Promise { - // TODO: POST multipart a /api/clientes/:id/documentos - await new Promise((resolve) => setTimeout(resolve, 200)) - console.log(`[api] subirDocumentos (stub) cliente=${clienteId}:`, payload.documentos.length, 'archivos') +/** + * Serializa el alta para el POST JSON: los File no viajan en el cuerpo, + * solo sus metadatos; la firma ya es un string base64 (data URL). + */ +const serializarAlta = (payload: AltaCliente) => ({ + ...payload, + documentos: payload.documentos.map( + (d): DocumentoMeta => ({ tipo: d.slot, nombre: d.nombre, tamano: d.tamano, mime: d.tipoMime }), + ), +}) + +/** + * Registra el alta en el API Gateway (POST /clientes). El backend persiste en + * SQLite y replica en Monday. Si el API no responde devuelve null (con + * console.warn) y el flujo continúa solo local: los PDFs se generan igual. + */ +export async function crearAltaCliente(payload: AltaCliente): Promise { + try { + const res = await fetch(`${API_URL}/clientes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(serializarAlta(payload)), + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = (await res.json()) as { id: string } + return { id: data.id } + } catch (err) { + console.warn('[api] crearAltaCliente: sin conexión al servidor, el expediente se genera solo localmente', err) + return null + } +} + +/** + * Sube los documentos reales del expediente (POST multipart + * /clientes/:id/documentos, campo 'archivos'). El backend acepta un solo + * `tipo` por petición, así que se envía una petición por documento con su + * slot como tipo. Los fallos se reportan con console.warn sin interrumpir + * el flujo local. + */ +export async function subirDocumentos(clienteId: string, payload: AltaCliente): Promise { + for (const doc of payload.documentos) { + try { + const form = new FormData() + form.append('tipo', doc.slot) + form.append('archivos', doc.archivo, doc.nombre) + const res = await fetch(`${API_URL}/clientes/${clienteId}/documentos`, { + method: 'POST', + body: form, + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + } catch (err) { + console.warn(`[api] subirDocumentos: no se pudo subir ${doc.nombre} (${doc.slot})`, err) + } + } } diff --git a/apps/alta-clientes/src/services/monday.ts b/apps/alta-clientes/src/services/monday.ts index 837d931..0f62333 100644 --- a/apps/alta-clientes/src/services/monday.ts +++ b/apps/alta-clientes/src/services/monday.ts @@ -1,10 +1,13 @@ import type { AltaCliente } from '../types' -/** Envía el alta al tablero de Monday (Contactos + Apartado) */ +/** + * OBSOLETO: la réplica en Monday (tableros Contactos y Apartado) ya la hace + * el API Gateway en POST /api/clientes (ver services/api.ts y + * api-gateway/src/controllers/clientes.controller.ts). Se conserva el + * archivo solo como referencia; no llamar desde el flujo del wizard. + */ export async function enviarAMonday(payload: AltaCliente): Promise { - // TODO: integración Monday — crear item en tablero "Contactos" y "Apartado" - // con nombre, teléfono, correo, proyecto y ubicación del cliente - console.log('[monday] enviarAMonday (stub):', { + console.warn('[monday] enviarAMonday está obsoleto: el backend ya replica en Monday', { nombre: payload.datosGenerales.nombreCompleto, proyecto: payload.viviendaCredito.proyecto, ubicacion: payload.viviendaCredito.ubicacion, diff --git a/apps/cobranza/src/pages/DashboardPage.tsx b/apps/cobranza/src/pages/DashboardPage.tsx index 2292f0f..bdbd187 100644 --- a/apps/cobranza/src/pages/DashboardPage.tsx +++ b/apps/cobranza/src/pages/DashboardPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { Bar, @@ -13,8 +13,8 @@ import { XAxis, YAxis, } from 'recharts' -import type { PlanPago, StatusPlan } from '../types' -import { cargarPlanes } from '../services/repository' +import type { StatusPlan } from '../types' +import { listarPlanes, type ResultadoPlanes } from '../services/repository' import { alCorriente, avancePlan, @@ -45,7 +45,8 @@ function claveMes(fecha: Date): string { } export default function DashboardPage() { - const [planes] = useState(() => cargarPlanes()) + const [resultado, setResultado] = useState(null) + const [errorCarga, setErrorCarga] = useState(null) // Filtros y ordenamiento persistidos en la URL para que sean compartibles const [searchParams, setSearchParams] = useSearchParams() const busqueda = searchParams.get('q') ?? '' @@ -56,6 +57,22 @@ export default function DashboardPage() { const dir: 'asc' | 'desc' = searchParams.get('dir') === 'desc' ? 'desc' : 'asc' const hoy = useMemo(() => new Date(), []) + useEffect(() => { + let activo = true + listarPlanes() + .then((r) => { + if (activo) setResultado(r) + }) + .catch((e: unknown) => { + if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar los planes') + }) + return () => { + activo = false + } + }, []) + + const planes = useMemo(() => resultado?.planes ?? [], [resultado]) + const actualizarParams = (cambios: Record) => { setSearchParams( (prev) => { @@ -134,6 +151,14 @@ export default function DashboardPage() { })) }, [planes, hoy]) + if (!resultado) { + return errorCarga ? ( +
{errorCarga}
+ ) : ( +

Cargando...

+ ) + } + const filtrados = derivados.filter((d) => { if (filtroStatus !== 'TODOS' && d.status !== filtroStatus) return false if (filtroProyecto !== 'TODOS' && d.plan.proyecto !== filtroProyecto) return false @@ -193,6 +218,15 @@ export default function DashboardPage() { + {/* Aviso discreto cuando los datos no vienen del API */} + {resultado.origen === 'local' && ( +

+ {resultado.planesSoloLocales + ? 'El API no tiene planes registrados. Los planes guardados en este navegador se conservan solo localmente (no se migraron automáticamente).' + : 'Modo offline: sin conexión al API; los cambios se guardan solo en este navegador.'} +

+ )} + {/* KPIs */}
diff --git a/apps/cobranza/src/pages/HoyPage.tsx b/apps/cobranza/src/pages/HoyPage.tsx index d26c5a6..bac08b8 100644 --- a/apps/cobranza/src/pages/HoyPage.tsx +++ b/apps/cobranza/src/pages/HoyPage.tsx @@ -1,15 +1,31 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' -import type { PlanPago } from '../types' -import { cargarPlanes } from '../services/repository' +import { listarPlanes, type ResultadoPlanes } from '../services/repository' import { cobranzaDelDia, round2, statusPago } from '../lib/cobranza' import { formatoFechaCorta, formatoMoneda } from '../lib/formatters' import KpiCard from '../components/KpiCard' /** Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de todos los planes activos */ export default function HoyPage() { - const [planes] = useState(() => cargarPlanes()) + const [resultado, setResultado] = useState(null) + const [errorCarga, setErrorCarga] = useState(null) const hoy = useMemo(() => new Date(), []) + + useEffect(() => { + let activo = true + listarPlanes() + .then((r) => { + if (activo) setResultado(r) + }) + .catch((e: unknown) => { + if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar los planes') + }) + return () => { + activo = false + } + }, []) + + const planes = useMemo(() => resultado?.planes ?? [], [resultado]) const items = useMemo(() => cobranzaDelDia(planes, hoy), [planes, hoy]) const vencidos = items.filter((i) => i.atraso > 0) @@ -18,6 +34,14 @@ export default function HoyPage() { items.reduce((acc, i) => acc + (i.pago.monto - i.pago.montoPagado), 0), ) + if (!resultado) { + return errorCarga ? ( +
{errorCarga}
+ ) : ( +

Cargando...

+ ) + } + return (
@@ -25,6 +49,11 @@ export default function HoyPage() {

Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos.

+ {resultado.origen === 'local' && ( +

+ Modo offline: sin conexión al API; mostrando datos guardados en este navegador. +

+ )}
diff --git a/apps/cobranza/src/pages/NuevoPlanPage.tsx b/apps/cobranza/src/pages/NuevoPlanPage.tsx index 99e85bc..c9620d5 100644 --- a/apps/cobranza/src/pages/NuevoPlanPage.tsx +++ b/apps/cobranza/src/pages/NuevoPlanPage.tsx @@ -1,8 +1,9 @@ +import { useState } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { useNavigate } from 'react-router-dom' -import { crearPlan } from '../services/repository' +import { crearPlanAsync } from '../services/repository' import { generarCalendario } from '../lib/cobranza' import { fechaISOHoy, formatoFechaCorta, formatoMoneda } from '../lib/formatters' @@ -42,6 +43,8 @@ const claseLabel = 'block text-xs font-bold uppercase tracking-widest text-neutr export default function NuevoPlanPage() { const navigate = useNavigate() + const [guardando, setGuardando] = useState(false) + const [errorGuardado, setErrorGuardado] = useState(null) const { register, handleSubmit, @@ -71,9 +74,17 @@ export default function NuevoPlanPage() { vistaPrevia = null } - const guardar = (datos: NuevoPlanForm) => { - const plan = crearPlan(datos) - void navigate(`/planes/${plan.id}`) + const guardar = async (datos: NuevoPlanForm) => { + setGuardando(true) + setErrorGuardado(null) + try { + const { plan } = await crearPlanAsync(datos) + void navigate(`/planes/${plan.id}`) + } catch (e) { + setErrorGuardado(e instanceof Error ? e.message : 'Error al guardar el plan') + } finally { + setGuardando(false) + } } return ( @@ -192,7 +203,8 @@ export default function NuevoPlanPage() {
)} -
+
+ {errorGuardado &&

{errorGuardado}

}
diff --git a/apps/cobranza/src/pages/PlanDetallePage.tsx b/apps/cobranza/src/pages/PlanDetallePage.tsx index a0b1063..bac5ef0 100644 --- a/apps/cobranza/src/pages/PlanDetallePage.tsx +++ b/apps/cobranza/src/pages/PlanDetallePage.tsx @@ -1,7 +1,12 @@ -import { Fragment, useMemo, useState } from 'react' +import { Fragment, useEffect, useMemo, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import type { Pago, PlanPago } from '../types' -import { agregarNota, cancelarPlan, obtenerPlan, registrarAbonoEnPlan } from '../services/repository' +import { + agregarNotaAsync, + cancelarPlanAsync, + obtenerPlanPorId, + registrarAbonoAsync, +} from '../services/repository' import { avancePlan, diasAtraso, @@ -23,7 +28,10 @@ import ModalAbono from '../components/ModalAbono' export default function PlanDetallePage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() - const [plan, setPlan] = useState(() => (id ? obtenerPlan(id) : undefined)) + const [plan, setPlan] = useState(undefined) + const [cargando, setCargando] = useState(true) + const [offline, setOffline] = useState(false) + const [errorCarga, setErrorCarga] = useState(null) const [pagoAbono, setPagoAbono] = useState(null) const [expandidos, setExpandidos] = useState>(new Set()) const [error, setError] = useState(null) @@ -31,6 +39,37 @@ export default function PlanDetallePage() { const [errorNota, setErrorNota] = useState(null) const hoy = useMemo(() => new Date(), []) + useEffect(() => { + if (!id) { + setCargando(false) + return + } + let activo = true + obtenerPlanPorId(id) + .then((r) => { + if (!activo) return + setPlan(r.plan) + setOffline(r.origen === 'local') + }) + .catch((e: unknown) => { + if (activo) setErrorCarga(e instanceof Error ? e.message : 'Error al cargar el plan') + }) + .finally(() => { + if (activo) setCargando(false) + }) + return () => { + activo = false + } + }, [id]) + + if (cargando) { + return

Cargando...

+ } + + if (errorCarga) { + return
{errorCarga}
+ } + if (!plan) { return (
@@ -57,11 +96,12 @@ export default function PlanDetallePage() { }) } - const guardarAbono = (datos: { monto: number; fechaPago: string; referencia: string }) => { + const guardarAbono = async (datos: { monto: number; fechaPago: string; referencia: string }) => { if (!pagoAbono) return try { - const actualizado = registrarAbonoEnPlan(plan.id, datos, pagoAbono.id) - setPlan(actualizado) + const r = await registrarAbonoAsync(plan.id, datos, pagoAbono.id) + setPlan(r.plan) + setOffline(r.origen === 'local') setPagoAbono(null) setError(null) } catch (e) { @@ -69,14 +109,23 @@ export default function PlanDetallePage() { } } - const cancelar = () => { + const cancelar = async () => { if (!window.confirm('¿Cancelar este plan? El saldo pendiente dejará de cobrarse.')) return - setPlan(cancelarPlan(plan.id)) + try { + const r = await cancelarPlanAsync(plan.id) + setPlan(r.plan) + setOffline(r.origen === 'local') + setError(null) + } catch (e) { + setError(e instanceof Error ? e.message : 'Error al cancelar el plan') + } } - const guardarNota = () => { + const guardarNota = async () => { try { - setPlan(agregarNota(plan.id, notaTexto)) + const r = await agregarNotaAsync(plan, notaTexto) + setPlan(r.plan) + setOffline(r.origen === 'local') setNotaTexto('') setErrorNota(null) } catch (e) { @@ -120,6 +169,12 @@ export default function PlanDetallePage() {
+ {offline && ( +

+ Modo offline: sin conexión al API; los cambios se guardan solo en este navegador. +

+ )} + {error && (
{error}
)} @@ -277,7 +332,7 @@ export default function PlanDetallePage() { className="mt-8" onSubmit={(e) => { e.preventDefault() - guardarNota() + void guardarNota() }} >