diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..6017cfe --- /dev/null +++ b/.cursorignore @@ -0,0 +1,4 @@ +!.env +!.env.test +!.env.docker +!.env.test \ No newline at end of file diff --git a/.env.example b/.env.example index 207a39a..bf1f99e 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,38 @@ +# Environment Configuration Example +# Copy this file to .env and update with your actual values + +# Application Environment NODE_ENV=development + +# Server Configuration PORT=3005 + +# Logging LOG_LEVEL=debug +# Database Configuration DATABASE_CONTAINER_NAME=container -DATABASE_PORT=5432 +DATABASE_PORT=5438 DATABASE_NAME=database DATABASE_USERNAME=username DATABASE_PASSWORD=passwd + +# Database URL (can use environment variables) DATABASE_URL=postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@localhost:${DATABASE_PORT}/${DATABASE_NAME} -DATABASE_AUTH_TOKEN=when-on-production +# Alternative: Direct database URL (if not using individual variables above) +# DATABASE_URL=postgresql://username:passwd@localhost:5438/database -# Email configuration -BREVO_API_KEY=your-brevo-api-key -EMAIL_FROM_ADDRESS=no-reply@example.com -EMAIL_FROM_NAME=App Name \ No newline at end of file +# JWT Secret (must be at least 32 characters long) +JWT_SECRET=your-secret-key-here-must-be-at-least-32-characters-long + +# Email Configuration (Brevo/Sendinblue) +BREVO_API_KEY=your-brevo-api-key-here +EMAIL_FROM_ADDRESS=your-email@example.com +EMAIL_FROM_NAME=Foppy AI + +# Frontend URL for email links +FRONTEND_URL=http://localhost:3001 + +# Database Auth Token (required in production) +DATABASE_AUTH_TOKEN=when-on-production diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 0000000..84b964b --- /dev/null +++ b/.env.test.example @@ -0,0 +1,39 @@ +# Environment Configuration for Tests +# Copy this file to .env.test and update with your test database values + +# IMPORTANT: Use a DIFFERENT database than your main database +# The database name MUST contain "test" to prevent accidental data loss + +NODE_ENV=test + +# Server Configuration (usually not needed for tests) +PORT=3005 + +# Logging +LOG_LEVEL=error + +# Test Database Configuration +# Option 1: Use individual variables +DATABASE_CONTAINER_NAME=container +DATABASE_PORT=5438 +DATABASE_NAME=database_test +DATABASE_USERNAME=username +DATABASE_PASSWORD=passwd + +# Option 2: Use TEST_DATABASE_URL (recommended) +# This should point to a DIFFERENT database than your main one +TEST_DATABASE_URL=postgresql://username:passwd@localhost:5438/database_test + +# JWT Secret for tests (must be at least 32 characters long) +JWT_SECRET=test-secret-key-for-jwt-testing-minimum-32-characters-long + +# Email Configuration (optional for tests, can use dummy values) +BREVO_API_KEY= +EMAIL_FROM_ADDRESS=test@example.com +EMAIL_FROM_NAME=Foppy AI Test + +# Frontend URL for email links (optional for tests) +FRONTEND_URL=http://localhost:3001 + +# Database Auth Token (not needed for tests) +# DATABASE_AUTH_TOKEN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 249865b..e98d3ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: - main jobs: - build: + test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -16,4 +16,4 @@ jobs: with: bun-version: latest - run: bun install - - run: npm run build + - run: bun run test diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..adb5558 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22.14.0 \ No newline at end of file diff --git a/TESTING_PLAN.md b/TESTING_PLAN.md new file mode 100644 index 0000000..aecd20e --- /dev/null +++ b/TESTING_PLAN.md @@ -0,0 +1,167 @@ +# Plan de Testing - Fopymes Backend + +## Objetivo +Implementar tests unitarios y de integración para las funcionalidades más importantes del sistema de finanzas personales. + +## Stack de Testing +- **Framework**: Bun Test (nativo de Bun) +- **Base de datos de pruebas**: PostgreSQL (contenedor Docker separado o base de datos de test) +- **Mocks**: Bun test mocking capabilities + +## Estructura de Tests + +``` +tests/ +├── unit/ +│ ├── utils/ +│ │ ├── crypto.util.test.ts +│ │ ├── jwt.util.test.ts +│ │ └── date.utils.test.ts +│ ├── services/ +│ │ ├── auth.service.test.ts +│ │ ├── transaction.service.test.ts +│ │ ├── goal.service.test.ts +│ │ └── goal-contribution.service.test.ts +│ └── cron/ +│ └── recalculate-contribution-amount.test.ts +├── integration/ +│ ├── auth.test.ts +│ ├── transactions.test.ts +│ ├── goals.test.ts +│ └── recommendations.test.ts +└── helpers/ + ├── test-db.ts + ├── test-helpers.ts + └── mocks.ts +``` + +## Features a Probar (Prioridad) + +### Alta Prioridad +1. **Autenticación (Auth)** + - Login con credenciales válidas/inválidas + - Registro de usuarios + - Recuperación de contraseña + - Validación de tokens JWT + +2. **Transacciones** + - Crear transacción (ingreso/gasto) + - Actualizar transacción + - Filtrar transacciones por fecha, tipo, categoría + - Validación de métodos de pago + +3. **Metas de Ahorro (Goals)** + - Crear meta + - Crear contribución a meta + - Actualizar progreso de meta + - Generar calendario de contribuciones + - Recalcular monto de contribución (cron job) + +4. **Recomendaciones** + - Obtener recomendaciones pendientes + - Marcar como vista/descartada/actuada + +### Media Prioridad +5. **Presupuestos (Budgets)** + - Crear presupuesto + - Validar límites de presupuesto + +6. **Deudas (Debts)** + - Crear deuda + - Registrar pago de deuda + +## Tests Unitarios + +### 1. Utilidades (Utils) +- **crypto.util.test.ts** + - `hash()`: Debe generar hash válido + - `verify()`: Debe verificar password correcto/incorrecto + +- **jwt.util.test.ts** + - `generateToken()`: Debe generar token JWT válido + - `verifyToken()`: Debe verificar token válido/inválido/expirado + +### 2. Servicios (Services) +- **auth.service.test.ts** + - Login exitoso + - Login con credenciales inválidas + - Registro exitoso + - Registro con email duplicado + - Recuperación de contraseña + +- **transaction.service.test.ts** + - Crear transacción válida + - Validar método de pago + - Filtrar transacciones + - Actualizar transacción + +- **goal.service.test.ts** + - Crear meta válida + - Validar usuario compartido + - Calcular progreso + +- **goal-contribution.service.test.ts** + - Crear contribución + - Actualizar progreso de meta + - Notificaciones de hitos + +### 3. Cron Jobs +- **recalculate-contribution-amount.test.ts** + - Recalcular monto para metas inactivas + - Generar notificaciones de recálculo + - Evitar duplicados + +## Tests de Integración + +### 1. Auth Endpoints +- `POST /auth/login` - 200, 400 +- `POST /auth/register` - 201, 400 +- `POST /auth/forgot-password` - 200, 404 + +### 2. Transaction Endpoints +- `POST /transactions` - 201, 400, 404 +- `GET /transactions?userId=X` - 200 +- `PUT /transactions/:id` - 200, 404 + +### 3. Goal Endpoints +- `POST /goals` - 201, 400, 404 +- `GET /goals?userId=X` - 200 +- `POST /goal-contributions` - 201, 400, 404 + +### 4. Recommendation Endpoints +- `GET /recommendations/pending?userId=X` - 200, 401 +- `PUT /recommendations/:id/viewed` - 200, 404 +- `PUT /recommendations/:id/dismissed` - 200, 404 + +## Configuración + +### Variables de Entorno de Test +```env +NODE_ENV=test +DATABASE_URL=postgresql://user:password@localhost:5432/fopymes_test +JWT_SECRET=test-secret-key +``` + +### Scripts en package.json +```json +{ + "test": "bun test", + "test:unit": "bun test tests/unit", + "test:integration": "bun test tests/integration", + "test:watch": "bun test --watch" +} +``` + +## Cobertura Objetivo +- **Cobertura mínima**: 60% de código crítico +- **Cobertura ideal**: 80% de código crítico +- **Enfoque**: Servicios de negocio, utilidades críticas, endpoints principales + +## Notas de Implementación +- Usar base de datos de test separada +- Limpiar datos entre tests +- Usar factories para crear datos de prueba +- Mockear servicios externos (email, etc.) +- Verificar cada suite de tests antes de continuar + + diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..f67c12e --- /dev/null +++ b/bun.lock @@ -0,0 +1,885 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "finance-app", + "dependencies": { + "@getbrevo/brevo": "^2.2.0", + "@hono/zod-openapi": "^0.18.3", + "@langchain/core": "^0.3.77", + "@scalar/hono-api-reference": "^0.5.162", + "@types/json2csv": "^5.0.7", + "@types/uuid": "^10.0.0", + "cron": "^4.3.0", + "date-fns": "^4.1.0", + "dotenv": "^16.4.5", + "dotenv-expand": "^12.0.1", + "drizzle-orm": "^0.38.2", + "drizzle-zod": "^0.6.0", + "exceljs": "^4.4.0", + "hono": "^4.6.12", + "hono-pino": "^0.7.0", + "i": "^0.3.7", + "jose": "^5.9.6", + "json2csv": "^6.0.0-alpha.2", + "langchain": "^0.3.34", + "npm": "^11.3.0", + "openai": "^6.9.1", + "pdf-lib": "^1.17.1", + "pdfkit": "^0.17.0", + "pg": "^8.13.1", + "pino": "^9.5.0", + "pino-pretty": "^13.0.0", + "stoker": "^1.4.2", + "zod": "^3.23.8", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/pdfkit": "^0.13.9", + "@types/pg": "^8.11.10", + "drizzle-kit": "^0.30.1", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.0.14", + }, + }, + }, + "packages": { + "@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@7.2.0", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^3.20.2" } }, ""], + + "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, ""], + + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, ""], + + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, ""], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, ""], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, ""], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.6.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, ""], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, ""], + + "@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, ""], + + "@eslint/js": ["@eslint/js@8.57.1", "", {}, ""], + + "@fast-csv/format": ["@fast-csv/format@4.3.5", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.isboolean": "^3.0.3", "lodash.isequal": "^4.5.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0" } }, ""], + + "@fast-csv/parse": ["@fast-csv/parse@4.3.6", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.groupby": "^4.6.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0", "lodash.isundefined": "^3.0.1", "lodash.uniq": "^4.5.0" } }, ""], + + "@getbrevo/brevo": ["@getbrevo/brevo@2.2.0", "", { "dependencies": { "bluebird": "^3.5.0", "request": "^2.81.0", "rewire": "^7.0.0" } }, ""], + + "@hono/zod-openapi": ["@hono/zod-openapi@0.18.3", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^7.1.0", "@hono/zod-validator": "^0.4.1" }, "peerDependencies": { "hono": ">=4.3.6", "zod": "3.*" } }, ""], + + "@hono/zod-validator": ["@hono/zod-validator@0.4.1", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, ""], + + "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, ""], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, ""], + + "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, ""], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, ""], + + "@langchain/core": ["@langchain/core@0.3.77", "", { "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", "camelcase": "6", "decamelize": "1.2.0", "js-tiktoken": "^1.0.12", "langsmith": "^0.3.67", "mustache": "^4.2.0", "p-queue": "^6.6.2", "p-retry": "4", "uuid": "^10.0.0", "zod": "^3.25.32", "zod-to-json-schema": "^3.22.3" } }, ""], + + "@langchain/openai": ["@langchain/openai@0.6.13", "", { "dependencies": { "js-tiktoken": "^1.0.12", "openai": "5.12.2", "zod": "^3.25.32" }, "peerDependencies": { "@langchain/core": ">=0.3.68 <0.4.0" } }, ""], + + "@langchain/textsplitters": ["@langchain/textsplitters@0.1.0", "", { "dependencies": { "js-tiktoken": "^1.0.12" }, "peerDependencies": { "@langchain/core": ">=0.2.21 <0.4.0" } }, ""], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, ""], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, ""], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, ""], + + "@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, ""], + + "@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, ""], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.53.3", "", { "os": "darwin", "cpu": "arm64" }, ""], + + "@scalar/hono-api-reference": ["@scalar/hono-api-reference@0.5.162", "", { "dependencies": { "@scalar/types": "0.0.22" }, "peerDependencies": { "hono": "^4.0.0" } }, ""], + + "@scalar/openapi-types": ["@scalar/openapi-types@0.1.5", "", {}, ""], + + "@scalar/types": ["@scalar/types@0.0.22", "", { "dependencies": { "@scalar/openapi-types": "0.1.5", "@unhead/schema": "^1.11.11" } }, ""], + + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, ""], + + "@streamparser/json": ["@streamparser/json@0.0.6", "", {}, ""], + + "@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, ""], + + "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, ""], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, ""], + + "@types/estree": ["@types/estree@1.0.8", "", {}, ""], + + "@types/json2csv": ["@types/json2csv@5.0.7", "", { "dependencies": { "@types/node": "*" } }, ""], + + "@types/luxon": ["@types/luxon@3.6.2", "", {}, ""], + + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + + "@types/pdfkit": ["@types/pdfkit@0.13.9", "", { "dependencies": { "@types/node": "*" } }, ""], + + "@types/pg": ["@types/pg@8.11.10", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, ""], + + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, ""], + + "@types/retry": ["@types/retry@0.12.0", "", {}, ""], + + "@types/uuid": ["@types/uuid@10.0.0", "", {}, ""], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, ""], + + "@unhead/schema": ["@unhead/schema@1.11.13", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, ""], + + "@vitest/expect": ["@vitest/expect@4.0.14", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.14", "@vitest/utils": "4.0.14", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, ""], + + "@vitest/mocker": ["@vitest/mocker@4.0.14", "", { "dependencies": { "@vitest/spy": "4.0.14", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw"] }, ""], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.0.14", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, ""], + + "@vitest/runner": ["@vitest/runner@4.0.14", "", { "dependencies": { "@vitest/utils": "4.0.14", "pathe": "^2.0.3" } }, ""], + + "@vitest/snapshot": ["@vitest/snapshot@4.0.14", "", { "dependencies": { "@vitest/pretty-format": "4.0.14", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, ""], + + "@vitest/spy": ["@vitest/spy@4.0.14", "", {}, ""], + + "@vitest/utils": ["@vitest/utils@4.0.14", "", { "dependencies": { "@vitest/pretty-format": "4.0.14", "tinyrainbow": "^3.0.3" } }, ""], + + "acorn": ["acorn@8.14.1", "", { "bin": "bin/acorn" }, ""], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, ""], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, ""], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, ""], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, ""], + + "archiver": ["archiver@5.3.2", "", { "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", "buffer-crc32": "^0.2.1", "readable-stream": "^3.6.0", "readdir-glob": "^1.1.2", "tar-stream": "^2.2.0", "zip-stream": "^4.1.0" } }, ""], + + "archiver-utils": ["archiver-utils@2.1.0", "", { "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^2.0.0" } }, ""], + + "argparse": ["argparse@2.0.1", "", {}, ""], + + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, ""], + + "assert-plus": ["assert-plus@1.0.0", "", {}, ""], + + "assertion-error": ["assertion-error@2.0.1", "", {}, ""], + + "async": ["async@3.2.6", "", {}, ""], + + "asynckit": ["asynckit@0.4.0", "", {}, ""], + + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, ""], + + "aws-sign2": ["aws-sign2@0.7.0", "", {}, ""], + + "aws4": ["aws4@1.13.2", "", {}, ""], + + "balanced-match": ["balanced-match@1.0.2", "", {}, ""], + + "base64-js": ["base64-js@1.5.1", "", {}, ""], + + "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, ""], + + "big-integer": ["big-integer@1.6.52", "", {}, ""], + + "binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, ""], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, ""], + + "bluebird": ["bluebird@3.7.2", "", {}, ""], + + "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, ""], + + "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, ""], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, ""], + + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, ""], + + "buffer-from": ["buffer-from@1.1.2", "", {}, ""], + + "buffer-indexof-polyfill": ["buffer-indexof-polyfill@1.0.2", "", {}, ""], + + "buffers": ["buffers@0.1.1", "", {}, ""], + + "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + + "callsites": ["callsites@3.1.0", "", {}, ""], + + "camelcase": ["camelcase@6.3.0", "", {}, ""], + + "caseless": ["caseless@0.12.0", "", {}, ""], + + "chai": ["chai@6.2.1", "", {}, ""], + + "chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, ""], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, ""], + + "clone": ["clone@2.1.2", "", {}, ""], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, ""], + + "color-name": ["color-name@1.1.4", "", {}, ""], + + "colorette": ["colorette@2.0.20", "", {}, ""], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, ""], + + "commander": ["commander@6.2.1", "", {}, ""], + + "compress-commons": ["compress-commons@4.1.2", "", { "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, ""], + + "concat-map": ["concat-map@0.0.1", "", {}, ""], + + "console-table-printer": ["console-table-printer@2.14.6", "", { "dependencies": { "simple-wcswidth": "^1.0.1" } }, ""], + + "core-util-is": ["core-util-is@1.0.2", "", {}, ""], + + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, ""], + + "crc32-stream": ["crc32-stream@4.0.3", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" } }, ""], + + "cron": ["cron@4.3.0", "", { "dependencies": { "@types/luxon": "~3.6.0", "luxon": "~3.6.0" } }, ""], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, ""], + + "crypto-js": ["crypto-js@4.2.0", "", {}, ""], + + "csstype": ["csstype@3.1.3", "", {}, ""], + + "dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, ""], + + "date-fns": ["date-fns@4.1.0", "", {}, ""], + + "dateformat": ["dateformat@4.6.3", "", {}, ""], + + "dayjs": ["dayjs@1.11.13", "", {}, ""], + + "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, ""], + + "decamelize": ["decamelize@1.2.0", "", {}, ""], + + "deep-is": ["deep-is@0.1.4", "", {}, ""], + + "defu": ["defu@6.1.4", "", {}, ""], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, ""], + + "dfa": ["dfa@1.2.0", "", {}, ""], + + "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, ""], + + "dotenv": ["dotenv@16.4.5", "", {}, ""], + + "dotenv-expand": ["dotenv-expand@12.0.1", "", { "dependencies": { "dotenv": "^16.4.5" } }, ""], + + "drizzle-kit": ["drizzle-kit@0.30.1", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0" }, "bin": "bin.cjs" }, ""], + + "drizzle-orm": ["drizzle-orm@0.38.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "expo-sqlite", "knex", "kysely", "mysql2", "postgres", "react", "sql.js", "sqlite3"] }, ""], + + "drizzle-zod": ["drizzle-zod@0.6.0", "", { "peerDependencies": { "drizzle-orm": ">=0.36.0", "zod": ">=3.0.0" } }, ""], + + "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, ""], + + "ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, ""], + + "end-of-stream": ["end-of-stream@1.4.4", "", { "dependencies": { "once": "^1.4.0" } }, ""], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, ""], + + "esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/darwin-arm64": "0.19.12" }, "bin": "bin/esbuild" }, ""], + + "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, ""], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, ""], + + "eslint": ["eslint@8.57.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", "@eslint/js": "8.57.1", "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": "bin/eslint.js" }, ""], + + "eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, ""], + + "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, ""], + + "espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, ""], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, ""], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, ""], + + "estraverse": ["estraverse@5.3.0", "", {}, ""], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, ""], + + "esutils": ["esutils@2.0.3", "", {}, ""], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, ""], + + "exceljs": ["exceljs@4.4.0", "", { "dependencies": { "archiver": "^5.0.0", "dayjs": "^1.8.34", "fast-csv": "^4.3.1", "jszip": "^3.10.1", "readable-stream": "^3.6.0", "saxes": "^5.0.1", "tmp": "^0.2.0", "unzipper": "^0.10.11", "uuid": "^8.3.0" } }, ""], + + "expect-type": ["expect-type@1.2.2", "", {}, ""], + + "extend": ["extend@3.0.2", "", {}, ""], + + "extsprintf": ["extsprintf@1.3.0", "", {}, ""], + + "fast-copy": ["fast-copy@3.0.2", "", {}, ""], + + "fast-csv": ["fast-csv@4.3.6", "", { "dependencies": { "@fast-csv/format": "4.3.5", "@fast-csv/parse": "4.3.6" } }, ""], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, ""], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, ""], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, ""], + + "fast-redact": ["fast-redact@3.5.0", "", {}, ""], + + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, ""], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, ""], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, ""], + + "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, ""], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, ""], + + "flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, ""], + + "flatted": ["flatted@3.3.3", "", {}, ""], + + "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, ""], + + "forever-agent": ["forever-agent@0.6.1", "", {}, ""], + + "form-data": ["form-data@2.3.3", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", "mime-types": "^2.1.12" } }, ""], + + "fs-constants": ["fs-constants@1.0.0", "", {}, ""], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, ""], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, ""], + + "fstream": ["fstream@1.0.12", "", { "dependencies": { "graceful-fs": "^4.1.2", "inherits": "~2.0.0", "mkdirp": ">=0.5 0", "rimraf": "2" } }, ""], + + "get-tsconfig": ["get-tsconfig@4.8.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, ""], + + "getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, ""], + + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, ""], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, ""], + + "globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, ""], + + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, ""], + + "graphemer": ["graphemer@1.4.0", "", {}, ""], + + "har-schema": ["har-schema@2.0.0", "", {}, ""], + + "har-validator": ["har-validator@5.1.5", "", { "dependencies": { "ajv": "^6.12.3", "har-schema": "^2.0.0" } }, ""], + + "has-flag": ["has-flag@4.0.0", "", {}, ""], + + "help-me": ["help-me@5.0.0", "", {}, ""], + + "hono": ["hono@4.6.12", "", {}, ""], + + "hono-pino": ["hono-pino@0.7.0", "", { "dependencies": { "defu": "^6.1.4" }, "peerDependencies": { "hono": ">=4.0.0", "pino": ">=7.1.0" } }, ""], + + "hookable": ["hookable@5.5.3", "", {}, ""], + + "http-signature": ["http-signature@1.2.0", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", "sshpk": "^1.7.0" } }, ""], + + "i": ["i@0.3.7", "", {}, ""], + + "ieee754": ["ieee754@1.2.1", "", {}, ""], + + "ignore": ["ignore@5.3.2", "", {}, ""], + + "immediate": ["immediate@3.0.6", "", {}, ""], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, ""], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, ""], + + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, ""], + + "inherits": ["inherits@2.0.4", "", {}, ""], + + "is-extglob": ["is-extglob@2.1.1", "", {}, ""], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, ""], + + "is-path-inside": ["is-path-inside@3.0.3", "", {}, ""], + + "is-typedarray": ["is-typedarray@1.0.0", "", {}, ""], + + "isarray": ["isarray@1.0.0", "", {}, ""], + + "isexe": ["isexe@2.0.0", "", {}, ""], + + "isstream": ["isstream@0.1.2", "", {}, ""], + + "jose": ["jose@5.9.6", "", {}, ""], + + "joycon": ["joycon@3.1.1", "", {}, ""], + + "jpeg-exif": ["jpeg-exif@1.1.4", "", {}, ""], + + "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, ""], + + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, ""], + + "jsbn": ["jsbn@0.1.1", "", {}, ""], + + "json-buffer": ["json-buffer@3.0.1", "", {}, ""], + + "json-schema": ["json-schema@0.4.0", "", {}, ""], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, ""], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, ""], + + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, ""], + + "json2csv": ["json2csv@6.0.0-alpha.2", "", { "dependencies": { "@streamparser/json": "^0.0.6", "commander": "^6.2.0", "lodash.get": "^4.4.2" }, "bin": "bin/json2csv.js" }, ""], + + "jsonpointer": ["jsonpointer@5.0.1", "", {}, ""], + + "jsprim": ["jsprim@1.4.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, ""], + + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, ""], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, ""], + + "langchain": ["langchain@0.3.34", "", { "dependencies": { "@langchain/openai": ">=0.1.0 <0.7.0", "@langchain/textsplitters": ">=0.0.0 <0.2.0", "js-tiktoken": "^1.0.12", "js-yaml": "^4.1.0", "jsonpointer": "^5.0.1", "langsmith": "^0.3.67", "openapi-types": "^12.1.3", "p-retry": "4", "uuid": "^10.0.0", "yaml": "^2.2.1", "zod": "^3.25.32" }, "peerDependencies": { "@langchain/anthropic": "*", "@langchain/aws": "*", "@langchain/cerebras": "*", "@langchain/cohere": "*", "@langchain/core": ">=0.3.58 <0.4.0", "@langchain/deepseek": "*", "@langchain/google-genai": "*", "@langchain/google-vertexai": "*", "@langchain/google-vertexai-web": "*", "@langchain/groq": "*", "@langchain/mistralai": "*", "@langchain/ollama": "*", "@langchain/xai": "*", "axios": "*", "cheerio": "*", "handlebars": "^4.7.8", "peggy": "^3.0.2", "typeorm": "*" }, "optionalPeers": ["@langchain/anthropic", "@langchain/aws", "@langchain/cerebras", "@langchain/cohere", "@langchain/deepseek", "@langchain/google-genai", "@langchain/google-vertexai", "@langchain/google-vertexai-web", "@langchain/groq", "@langchain/mistralai", "@langchain/ollama", "@langchain/xai", "axios", "cheerio", "handlebars", "peggy", "typeorm"] }, ""], + + "langsmith": ["langsmith@0.3.71", "", { "dependencies": { "@types/uuid": "^10.0.0", "chalk": "^4.1.2", "console-table-printer": "^2.12.1", "p-queue": "^6.6.2", "p-retry": "4", "semver": "^7.6.3", "uuid": "^10.0.0" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base"] }, ""], + + "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, ""], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, ""], + + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, ""], + + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, ""], + + "listenercount": ["listenercount@1.0.1", "", {}, ""], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, ""], + + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, ""], + + "lodash.difference": ["lodash.difference@4.5.0", "", {}, ""], + + "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, ""], + + "lodash.flatten": ["lodash.flatten@4.4.0", "", {}, ""], + + "lodash.get": ["lodash.get@4.4.2", "", {}, ""], + + "lodash.groupby": ["lodash.groupby@4.6.0", "", {}, ""], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, ""], + + "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, ""], + + "lodash.isfunction": ["lodash.isfunction@3.0.9", "", {}, ""], + + "lodash.isnil": ["lodash.isnil@4.0.0", "", {}, ""], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, ""], + + "lodash.isundefined": ["lodash.isundefined@3.0.1", "", {}, ""], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, ""], + + "lodash.union": ["lodash.union@4.6.0", "", {}, ""], + + "lodash.uniq": ["lodash.uniq@4.5.0", "", {}, ""], + + "luxon": ["luxon@3.6.1", "", {}, ""], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, ""], + + "mime-db": ["mime-db@1.52.0", "", {}, ""], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, ""], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, ""], + + "minimist": ["minimist@1.2.8", "", {}, ""], + + "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": "bin/cmd.js" }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + + "ms": ["ms@2.1.3", "", {}, ""], + + "mustache": ["mustache@4.2.0", "", { "bin": "bin/mustache" }, ""], + + "nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, ""], + + "natural-compare": ["natural-compare@1.4.0", "", {}, ""], + + "normalize-path": ["normalize-path@3.0.0", "", {}, ""], + + "npm": ["npm@11.3.0", "", { "bin": { "npm": "bin/npm-cli.js", "npx": "bin/npx-cli.js" } }, ""], + + "oauth-sign": ["oauth-sign@0.9.0", "", {}, ""], + + "obuf": ["obuf@1.1.2", "", {}, ""], + + "obug": ["obug@2.1.1", "", {}, ""], + + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, ""], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, ""], + + "openai": ["openai@6.9.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-vQ5Rlt0ZgB3/BNmTa7bIijYFhz3YBceAA3Z4JuoMSBftBF9YqFHIEhZakSs+O/Ad7EaoEimZvHxD5ylRjN11Lg=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, ""], + + "openapi3-ts": ["openapi3-ts@4.4.0", "", { "dependencies": { "yaml": "^2.5.0" } }, ""], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, ""], + + "p-finally": ["p-finally@1.0.0", "", {}, ""], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, ""], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, ""], + + "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, ""], + + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, ""], + + "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, ""], + + "pako": ["pako@1.0.11", "", {}, ""], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, ""], + + "path-exists": ["path-exists@4.0.0", "", {}, ""], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, ""], + + "path-key": ["path-key@3.1.1", "", {}, ""], + + "pathe": ["pathe@2.0.3", "", {}, ""], + + "pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, ""], + + "pdfkit": ["pdfkit@0.17.0", "", { "dependencies": { "crypto-js": "^4.2.0", "fontkit": "^2.0.4", "jpeg-exif": "^1.1.4", "linebreak": "^1.1.0", "png-js": "^1.0.0" } }, ""], + + "performance-now": ["performance-now@2.1.0", "", {}, ""], + + "pg": ["pg@8.13.1", "", { "dependencies": { "pg-connection-string": "^2.7.0", "pg-pool": "^3.7.0", "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, "optionalDependencies": { "pg-cloudflare": "^1.1.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, ""], + + "pg-cloudflare": ["pg-cloudflare@1.1.1", "", {}, ""], + + "pg-connection-string": ["pg-connection-string@2.7.0", "", {}, ""], + + "pg-int8": ["pg-int8@1.0.1", "", {}, ""], + + "pg-numeric": ["pg-numeric@1.0.2", "", {}, ""], + + "pg-pool": ["pg-pool@3.7.0", "", { "peerDependencies": { "pg": ">=8.0" } }, ""], + + "pg-protocol": ["pg-protocol@1.7.0", "", {}, ""], + + "pg-types": ["pg-types@4.0.2", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, ""], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, ""], + + "picocolors": ["picocolors@1.1.1", "", {}, ""], + + "picomatch": ["picomatch@4.0.3", "", {}, ""], + + "pino": ["pino@9.5.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^4.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": "bin.js" }, ""], + + "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, ""], + + "pino-pretty": ["pino-pretty@13.0.0", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^3.0.2", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pump": "^3.0.0", "secure-json-parse": "^2.4.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^3.1.1" }, "bin": "bin.js" }, ""], + + "pino-std-serializers": ["pino-std-serializers@7.0.0", "", {}, ""], + + "png-js": ["png-js@1.0.0", "", {}, ""], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, ""], + + "postgres-array": ["postgres-array@3.0.2", "", {}, ""], + + "postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, ""], + + "postgres-date": ["postgres-date@2.1.0", "", {}, ""], + + "postgres-interval": ["postgres-interval@3.0.0", "", {}, ""], + + "postgres-range": ["postgres-range@1.1.4", "", {}, ""], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, ""], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, ""], + + "process-warning": ["process-warning@4.0.0", "", {}, ""], + + "psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, ""], + + "pump": ["pump@3.0.2", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, ""], + + "punycode": ["punycode@2.3.1", "", {}, ""], + + "qs": ["qs@6.5.3", "", {}, ""], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, ""], + + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, ""], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, ""], + + "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, ""], + + "real-require": ["real-require@0.2.0", "", {}, ""], + + "request": ["request@2.88.2", "", { "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", "caseless": "~0.12.0", "combined-stream": "~1.0.6", "extend": "~3.0.2", "forever-agent": "~0.6.1", "form-data": "~2.3.2", "har-validator": "~5.1.3", "http-signature": "~1.2.0", "is-typedarray": "~1.0.0", "isstream": "~0.1.2", "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "oauth-sign": "~0.9.0", "performance-now": "^2.1.0", "qs": "~6.5.2", "safe-buffer": "^5.1.2", "tough-cookie": "~2.5.0", "tunnel-agent": "^0.6.0", "uuid": "^3.3.2" } }, ""], + + "resolve-from": ["resolve-from@4.0.0", "", {}, ""], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, ""], + + "restructure": ["restructure@3.0.2", "", {}, ""], + + "retry": ["retry@0.13.1", "", {}, ""], + + "reusify": ["reusify@1.1.0", "", {}, ""], + + "rewire": ["rewire@7.0.0", "", { "dependencies": { "eslint": "^8.47.0" } }, ""], + + "rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": "bin.js" }, ""], + + "rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "4.53.3", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, ""], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, ""], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, ""], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, ""], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, ""], + + "saxes": ["saxes@5.0.1", "", { "dependencies": { "xmlchars": "^2.2.0" } }, ""], + + "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, ""], + + "semver": ["semver@7.7.1", "", { "bin": "bin/semver.js" }, ""], + + "setimmediate": ["setimmediate@1.0.5", "", {}, ""], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, ""], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, ""], + + "siginfo": ["siginfo@2.0.0", "", {}, ""], + + "simple-wcswidth": ["simple-wcswidth@1.1.2", "", {}, ""], + + "sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, ""], + + "source-map": ["source-map@0.6.1", "", {}, ""], + + "source-map-js": ["source-map-js@1.2.1", "", {}, ""], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, ""], + + "split2": ["split2@4.2.0", "", {}, ""], + + "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, ""], + + "stackback": ["stackback@0.0.2", "", {}, ""], + + "std-env": ["std-env@3.10.0", "", {}, ""], + + "stoker": ["stoker@1.4.2", "", { "peerDependencies": { "@asteasolutions/zod-to-openapi": "^7.0.0", "@hono/zod-openapi": ">=0.16.0", "hono": "^4.0.0", "openapi3-ts": "^4.4.0" } }, ""], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, ""], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, ""], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, ""], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, ""], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, ""], + + "text-table": ["text-table@0.2.0", "", {}, ""], + + "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, ""], + + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, ""], + + "tinybench": ["tinybench@2.9.0", "", {}, ""], + + "tinyexec": ["tinyexec@0.3.2", "", {}, ""], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, ""], + + "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, ""], + + "tmp": ["tmp@0.2.3", "", {}, ""], + + "tough-cookie": ["tough-cookie@2.5.0", "", { "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" } }, ""], + + "traverse": ["traverse@0.3.9", "", {}, ""], + + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + + "tslib": ["tslib@1.14.1", "", {}, ""], + + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, ""], + + "tweetnacl": ["tweetnacl@0.14.5", "", {}, ""], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, ""], + + "type-fest": ["type-fest@0.20.2", "", {}, ""], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, ""], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, ""], + + "unzipper": ["unzipper@0.10.14", "", { "dependencies": { "big-integer": "^1.6.17", "binary": "~0.3.0", "bluebird": "~3.4.1", "buffer-indexof-polyfill": "~1.0.0", "duplexer2": "~0.1.4", "fstream": "^1.0.12", "graceful-fs": "^4.2.2", "listenercount": "~1.0.1", "readable-stream": "~2.3.6", "setimmediate": "~1.0.4" } }, ""], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, ""], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, ""], + + "uuid": ["uuid@10.0.0", "", { "bin": "dist/bin/uuid" }, ""], + + "verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, ""], + + "vite": ["vite@7.2.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "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" }, "optionalPeers": ["jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx"], "bin": "bin/vite.js" }, ""], + + "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], + + "vitest": ["vitest@4.0.14", "", { "dependencies": { "@vitest/expect": "4.0.14", "@vitest/mocker": "4.0.14", "@vitest/pretty-format": "4.0.14", "@vitest/runner": "4.0.14", "@vitest/snapshot": "4.0.14", "@vitest/spy": "4.0.14", "@vitest/utils": "4.0.14", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.14", "@vitest/browser-preview": "4.0.14", "@vitest/browser-webdriverio": "4.0.14", "@vitest/ui": "4.0.14", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, ""], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, ""], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, ""], + + "word-wrap": ["word-wrap@1.2.5", "", {}, ""], + + "wrappy": ["wrappy@1.0.2", "", {}, ""], + + "xmlchars": ["xmlchars@2.2.0", "", {}, ""], + + "xtend": ["xtend@4.0.2", "", {}, ""], + + "yaml": ["yaml@2.6.1", "", { "bin": "bin.mjs" }, ""], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, ""], + + "zhead": ["zhead@2.2.4", "", {}, ""], + + "zip-stream": ["zip-stream@4.1.1", "", { "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", "readable-stream": "^3.6.0" } }, ""], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, ""], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/darwin-arm64": "0.18.20" }, "bin": "bin/esbuild" }, ""], + + "@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, ""], + + "@fast-csv/format/@types/node": ["@types/node@14.18.63", "", {}, ""], + + "@fast-csv/parse/@types/node": ["@types/node@14.18.63", "", {}, ""], + + "@langchain/openai/openai": ["openai@5.12.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws"], "bin": "bin/cli" }, ""], + + "@swc/helpers/tslib": ["tslib@2.8.1", "", {}, ""], + + "archiver-utils/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, ""], + + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, ""], + + "duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, ""], + + "exceljs/uuid": ["uuid@8.3.2", "", { "bin": "dist/bin/uuid" }, ""], + + "flat-cache/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": "bin.js" }, ""], + + "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, ""], + + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, ""], + + "linebreak/base64-js": ["base64-js@0.0.8", "", {}, ""], + + "pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, ""], + + "readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, ""], + + "request/uuid": ["uuid@3.4.0", "", { "bin": "bin/uuid" }, ""], + + "unicode-trie/pako": ["pako@0.2.9", "", {}, ""], + + "unzipper/bluebird": ["bluebird@3.4.7", "", {}, ""], + + "unzipper/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, ""], + + "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/darwin-arm64": "0.25.12" }, "bin": "bin/esbuild" }, ""], + + "zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, ""], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, ""], + + "archiver-utils/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, ""], + + "archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, ""], + + "duplexer2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, ""], + + "duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, ""], + + "jszip/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, ""], + + "jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, ""], + + "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, ""], + + "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, ""], + + "pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, ""], + + "pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.0", "", {}, ""], + + "pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, ""], + + "pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, ""], + + "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, ""], + + "unzipper/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, ""], + + "unzipper/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, ""], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, ""], + } +} diff --git a/bun.lockb b/bun.lockb deleted file mode 100755 index bb2bca5..0000000 Binary files a/bun.lockb and /dev/null differ diff --git a/documentation/BACK_PROJECT_DOC.md b/documentation/BACK_PROJECT_DOC.md new file mode 100644 index 0000000..6e20877 --- /dev/null +++ b/documentation/BACK_PROJECT_DOC.md @@ -0,0 +1,1302 @@ +# FoppyAI - Documentación Completa del Proyecto + +## Índice +1. [Información General](#información-general) +2. [Arquitectura del Sistema](#arquitectura-del-sistema) +3. [Backend (API REST)](#backend-api-rest) +4. [Base de Datos](#base-de-datos) +5. [Características Implementadas](#características-implementadas) +6. [Tareas Pendientes y Bugs](#tareas-pendientes-y-bugs) +7. [Flujos de Datos](#flujos-de-datos) +8. [Configuración y Deployment](#configuración-y-deployment) + +--- + +## 1. Información General + +### Descripción del Proyecto +FoppyAI (anteriormente Fopymes) es un sistema de gestión de finanzas personales con capacidades de inteligencia artificial que permite a los usuarios controlar sus ingresos, gastos, metas de ahorro, presupuestos y deudas. + +### Stack Tecnológico + +#### Backend +- **Runtime**: Bun (JavaScript runtime y package manager) +- **Framework**: Hono.js v4.6.12 +- **Base de Datos**: PostgreSQL +- **ORM**: Drizzle ORM v0.38.2 +- **Validación**: Zod v3.23.8 +- **Autenticación**: JWT con jose v5.9.6 +- **Documentación API**: OpenAPI con @scalar/hono-api-reference +- **IA/ML**: LangChain v0.3.34, @langchain/core v0.3.77 +- **Generación de Reportes**: + - Excel: exceljs v4.4.0 + - CSV: json2csv v6.0.0-alpha.2 + - PDF: pdf-lib v1.17.1, pdfkit v0.17.0 +- **Emails**: @getbrevo/brevo v2.2.0 +- **Tareas programadas**: cron v4.3.0 +- **Logging**: pino v9.5.0, hono-pino v0.7.0 +- **Utilidades**: date-fns v4.1.0, uuid v10.0.0 + +#### Frontend +**Nota**: Se requiere acceso a `/Users/jair/fopyments-app` para documentar completamente el frontend. La carpeta mencionada no está en los directorios permitidos actualmente. + +### Directorios del Proyecto +- **Backend**: `/Users/jair/devProjects/fopymes-backend` +- **Frontend**: `/Users/jair/fopyments-app` (pendiente de acceso) +- **Base de Datos**: PostgreSQL - nombre: "database" + +--- + +## 2. Arquitectura del Sistema + +### Patrón Arquitectónico +El proyecto sigue una **Arquitectura Hexagonal (Ports & Adapters)** con principios de Clean Architecture. + +### Estructura del Backend + +``` +src/ +├── core/ +│ └── infrastructure/ +│ ├── database/ # Configuración de base de datos +│ │ ├── index.ts # DatabaseConnection (Singleton) +│ │ └── schema.ts # Esquemas Drizzle ORM +│ ├── env/ # Variables de entorno +│ ├── lib/ # Librerías compartidas +│ │ ├── create-app.ts # Factory de aplicación Hono +│ │ ├── configure-open-api.ts +│ │ └── handler.wrapper.ts +│ ├── middleware/ # Middleware global +│ ├── types/ # Tipos globales +│ ├── cron/ # Trabajos programados +│ │ ├── budget-notifications.cron.ts +│ │ ├── debt-notifications.cron.ts +│ │ ├── expired-notifications.cron.ts +│ │ ├── financial-suggestions.cron.ts +│ │ ├── goal-notifications.cron.ts +│ │ ├── goal-suggestions.cron.ts +│ │ ├── recalculate-contribution-amount.cron.ts +│ │ └── scheduled-transactions.cron.ts +│ └── scripts/ # Scripts de utilidad +│ ├── categories.seed.ts +│ └── clean-database.ts +│ +├── features/ # Módulos por dominio +│ ├── ai-agents/ +│ │ ├── application/ # Lógica de negocio +│ │ │ ├── dtos/ +│ │ │ └── services/ +│ │ ├── domain/ # Entidades y puertos +│ │ │ ├── entities/ +│ │ │ └── ports/ +│ │ └── infrastructure/ # Adaptadores +│ │ ├── adapters/ +│ │ └── controllers/ +│ ├── auth/ +│ ├── budgets/ +│ ├── categories/ +│ ├── debts/ +│ ├── email/ +│ ├── friends/ +│ ├── goals/ +│ │ └── infrastucture/ # Nota: typo en el código original +│ ├── notifications/ +│ ├── payment-methods/ +│ ├── reports/ +│ ├── scheduled-transactions/ +│ ├── transactions/ +│ └── users/ +│ +└── shared/ # Código compartido + ├── utils/ # Funciones puras + └── services/ # Servicios compartidos +``` + +### Capas de la Arquitectura Hexagonal + +#### 1. Domain (Dominio) +- **Entidades**: Objetos de negocio puros +- **Ports (Puertos)**: Interfaces que definen contratos +- **Casos de uso**: Lógica de negocio independiente + +#### 2. Application (Aplicación) +- **DTOs**: Data Transfer Objects +- **Services**: Servicios de aplicación +- **Use Cases**: Implementación de casos de uso + +#### 3. Infrastructure (Infraestructura) +- **Adapters**: Implementaciones de puertos + - Repositorios (PgXxxRepository) + - Servicios externos +- **Controllers**: Controladores HTTP +- **Routes**: Definiciones de rutas OpenAPI + +--- + +## 3. Backend (API REST) + +### Punto de Entrada +**Archivo**: `src/index.ts` +```typescript +import { serve } from "bun"; +import app from "./app"; +import env from "@/env"; + +serve({ + fetch: app.fetch, + port: env.PORT, +}); +``` + +### Configuración de la Aplicación +**Archivo**: `src/app.ts` + +#### Middleware Configurado +1. **CORS**: Configurado para localhost:3000, localhost:3001 y origen wildcard +2. **Logger**: Logging con pino +3. **Body Logger**: Middleware personalizado para loggear request bodies +4. **Authentication**: JWT validation en rutas protegidas + +#### Rutas Registradas +```typescript +const routes = [ + index, // GET / + auth, // /auth/* + users, // /users/* + paymentMethods, // /payment-methods/* + transactions, // /transactions/* + goals, // /goals/* + budgets, // /budgets/* + scheduledTransactions, // /scheduled-transactions/* + debts, // /debts/* + friends, // /friends/* + categories, // /categories/* + goalContributions, // /goal-contributions/* + goalContributionSchedules, // /goal-contribution-schedules/* + notifications, // /notifications/* + email, // /email/* + reports, // /reports/* + aiAgents, // /voice-command + notificationSocket, // WebSocket para notificaciones +] +``` + +#### Trabajos Cron Iniciados +```typescript +// Comentado: startScheduledTransactionsJob(); +startNotificationsCleanupJob(); // Limpieza de notificaciones expiradas +recalculateContributionAmountCron.start(); // Recalcula contribuciones a metas +startDebtNotificationsJob(); // Notificaciones de deudas +startBudgetSummaryJob(); // Resumen de presupuestos +startGoalNotificationsJob(); // Notificaciones de metas +startFinancialSuggestionsJob(); // Sugerencias financieras +startGoalSuggestionsJob(); // Sugerencias de metas +``` + +### Path Aliases (tsconfig.json) +```typescript +{ + "@/env": "./src/core/infrastructure/env/env.ts", + "@/db": "./src/core/infrastructure/database/index.ts", + "@/schema": "./src/core/infrastructure/database/schema.ts", + "@/shared/*": "./src/shared/*", + "@/users/*": "./src/features/users/*", + "@/auth/*": "./src/features/auth/*", + "@/payment-methods/*": "./src/features/payment-methods/*", + "@/transactions/*": "./src/features/transactions/*", + "@/goals/*": "./src/features/goals/*", + "@/budgets/*": "./src/features/budgets/*", + "@/scheduled-transactions/*": "./src/features/scheduled-transactions/*", + "@/debts/*": "./src/features/debts/*", + "@/categories/*": "./src/features/categories/*", + "@/friends/*": "./src/features/friends/*", + "@/email/*": "./src/features/email/*", + "@/core/*": "./src/core/*", + "@/notifications/*": "./src/features/notifications/*" +} +``` + +### Scripts Disponibles +```json +{ + "dev": "bun run --hot src/index.ts", + "build": "bun build src/index.ts --outdir dist --target node --external bun", + "migrate": "bun drizzle-kit migrate", + "generate": "bun drizzle-kit generate", + "db:seed": "bun run src/core/infrastructure/scripts/categories.seed.ts", + "db:clean": "bun run src/core/infrastructure/scripts/clean-database.ts", + "db:refresh": "bun run db:clean && bun run db:seed" +} +``` + +--- + +## 4. Base de Datos + +### Tablas en PostgreSQL + +#### 1. **users** - Usuarios del sistema +```sql +- id (serial, PK) +- name (varchar, NOT NULL) +- username (varchar, NOT NULL, UNIQUE) +- email (varchar, NOT NULL, UNIQUE) +- password_hash (varchar, NOT NULL) +- registration_date (timestamp, DEFAULT CURRENT_TIMESTAMP) +- active (boolean, DEFAULT true) +- recovery_token (varchar, NULLABLE) +- recovery_token_expires (timestamp, NULLABLE) +- created_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +- updated_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +``` + +#### 2. **categories** - Categorías de transacciones +```sql +- id (serial, PK) +- name (varchar, NOT NULL, UNIQUE) +- description (text) +- created_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +- updated_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +``` + +#### 3. **payment_methods** - Métodos de pago +```sql +- id (serial, PK) +- user_id (integer, FK -> users.id, NOT NULL) +- shared_user_id (integer, FK -> users.id, NULLABLE) +- name (varchar, NOT NULL) +- type (varchar, NOT NULL) +- last_four_digits (varchar, NULLABLE) +- created_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +- updated_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +Índices: pm_user_idx, pm_shared_user_idx +``` + +#### 4. **transactions** - Transacciones financieras +```sql +- id (serial, PK) +- user_id (integer, FK -> users.id, NOT NULL) +- amount (decimal(10,2), NOT NULL) +- type (varchar, NOT NULL) -- 'INCOME' | 'EXPENSE' +- category_id (integer, FK -> categories.id) +- description (text) +- payment_method_id (integer, FK -> payment_methods.id) +- date (timestamp, DEFAULT CURRENT_TIMESTAMP) +- scheduled_transaction_id (integer) +- debt_id (integer, FK -> debts.id) +- contribution_id (integer, FK -> goal_contributions.id) +- budget_id (integer, FK -> budgets.id) +- created_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +- updated_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +Índices: tx_user_idx, tx_date_idx, tx_category_idx, tx_budget_idx +``` + +#### 5. **goals** - Metas de ahorro +```sql +- id (serial, PK) +- user_id (integer, FK -> users.id, NOT NULL) +- shared_user_id (integer, FK -> users.id, NULLABLE) +- name (varchar, NOT NULL) +- target_amount (decimal(10,2), NOT NULL) +- current_amount (decimal(10,2), DEFAULT 0) +- end_date (timestamp, NOT NULL) +- contribution_frequency (integer, NOT NULL) -- En días +- contribution_amount (decimal(10,2), NOT NULL) +- category_id (integer, FK -> categories.id) +- created_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +- updated_at (timestamp, DEFAULT CURRENT_TIMESTAMP) +Índices: goals_user_idx, goals_shared_user_idx +``` + +#### 6. **goal_contributions** - Contribuciones a metas +```sql +- id (serial, PK) +- goal_id (integer, FK -> goals.id) +- amount (decimal(10,2)) +- date (timestamp) +- transaction_id (integer, FK -> transactions.id) +- created_at (timestamp) +- updated_at (timestamp) +``` + +#### 7. **goal_contribution_schedule** - Programación de contribuciones +```sql +- id (serial, PK) +- goal_id (integer, FK -> goals.id) +- scheduled_date (timestamp) +- amount (decimal(10,2)) +- status (varchar) +- created_at (timestamp) +- updated_at (timestamp) +``` + +#### 8. **budgets** - Presupuestos +```sql +- id (serial, PK) +- user_id (integer, FK -> users.id) +- category_id (integer, FK -> categories.id) +- amount (decimal(10,2)) +- period (varchar) -- 'MONTHLY' | 'WEEKLY' | etc. +- start_date (timestamp) +- end_date (timestamp) +- created_at (timestamp) +- updated_at (timestamp) +``` + +#### 9. **scheduled_transactions** - Transacciones programadas +```sql +- id (serial, PK) +- user_id (integer, FK -> users.id) +- amount (decimal(10,2)) +- type (varchar) +- category_id (integer, FK -> categories.id) +- description (text) +- payment_method_id (integer, FK -> payment_methods.id) +- frequency (varchar) -- 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' +- next_execution_date (timestamp) +- active (boolean) +- created_at (timestamp) +- updated_at (timestamp) +``` + +#### 10. **debts** - Deudas +```sql +- id (serial, PK) +- user_id (integer, FK -> users.id) +- creditor_name (varchar) +- total_amount (decimal(10,2)) +- remaining_amount (decimal(10,2)) +- interest_rate (decimal(5,2)) +- due_date (timestamp) +- status (varchar) +- created_at (timestamp) +- updated_at (timestamp) +``` + +#### 11. **friends** - Relaciones entre usuarios +```sql +- id (serial, PK) +- user_id (integer, FK -> users.id) +- friend_id (integer, FK -> users.id) +- status (varchar) -- 'PENDING' | 'ACCEPTED' | 'REJECTED' +- created_at (timestamp) +- updated_at (timestamp) +``` + +#### 12. **notifications** - Notificaciones del sistema +```sql +- id (serial, PK) +- user_id (integer, FK -> users.id) +- type (varchar) +- title (varchar) +- message (text) +- read (boolean) +- data (jsonb) +- created_at (timestamp) +- expires_at (timestamp) +``` + +#### 13. **reports** - Reportes generados +```sql +- id (varchar, PK) -- UUID +- user_id (integer, FK -> users.id) +- type (varchar) -- Ver ReportType enum +- format (varchar) -- 'JSON' | 'PDF' | 'EXCEL' | 'CSV' +- filters (jsonb) +- data (jsonb) +- created_at (timestamp) +- expires_at (timestamp) +``` + +### Relaciones Principales +1. **users** 1:N con **transactions**, **goals**, **budgets**, **debts**, **notifications**, **reports** +2. **users** N:N con **users** (friends) +3. **goals** 1:N con **goal_contributions** +4. **transactions** N:1 con **categories**, **payment_methods**, **budgets**, **debts** +5. **payment_methods** puede ser compartido entre usuarios + +--- + +## 5. Características Implementadas + +### 5.1 Autenticación y Usuarios +**Módulo**: `features/auth` y `features/users` + +#### Funcionalidades +- Registro de usuarios +- Login con JWT +- Recuperación de contraseña (tokens temporales) +- Perfil de usuario +- Gestión de sesiones + +#### Endpoints Principales +``` +POST /auth/register +POST /auth/login +POST /auth/forgot-password +POST /auth/reset-password +GET /users/profile +PUT /users/profile +``` + +### 5.2 Métodos de Pago +**Módulo**: `features/payment-methods` + +#### Funcionalidades +- CRUD de métodos de pago +- Soporte para compartir métodos entre usuarios +- Tipos: efectivo, tarjeta de crédito, tarjeta de débito, etc. + +### 5.3 Categorías +**Módulo**: `features/categories` + +#### Funcionalidades +- Categorías predefinidas del sistema +- CRUD de categorías +- Seed inicial de categorías comunes + +### 5.4 Transacciones +**Módulo**: `features/transactions` + +#### Funcionalidades +- Registro de ingresos y gastos +- Filtrado por fecha, categoría, tipo +- Vinculación con presupuestos, deudas y metas +- Soporte para transacciones recurrentes + +#### Tipos de Transacciones +- **INCOME**: Ingresos +- **EXPENSE**: Gastos + +### 5.5 Metas de Ahorro +**Módulo**: `features/goals` + +#### Funcionalidades +- Creación de metas con monto objetivo y fecha límite +- Frecuencia de contribución configurable +- Seguimiento de progreso (current_amount vs target_amount) +- Metas compartidas entre usuarios +- Contribuciones manuales y automáticas +- Programación de contribuciones + +#### Endpoints +``` +POST /goals +GET /goals +GET /goals/:id +PUT /goals/:id +DELETE /goals/:id +POST /goals/:id/contributions +GET /goals/:id/contributions +``` + +### 5.6 Presupuestos +**Módulo**: `features/budgets` + +#### Funcionalidades +- Presupuestos por categoría +- Períodos: mensual, semanal, anual +- Tracking automático de gastos vs presupuesto +- Alertas cuando se excede el presupuesto + +### 5.7 Transacciones Programadas +**Módulo**: `features/scheduled-transactions` + +#### Funcionalidades +- Transacciones recurrentes automáticas +- Frecuencias: diaria, semanal, mensual, anual +- Activación/desactivación de programaciones +- Generación automática vía cron job (actualmente deshabilitado) + +### 5.8 Deudas +**Módulo**: `features/debts` + +#### Funcionalidades +- Registro de deudas con acreedor +- Tasa de interés +- Tracking de pagos parciales +- Fecha de vencimiento +- Estados: activa, pagada, vencida + +### 5.9 Amigos/Conexiones +**Módulo**: `features/friends` + +#### Funcionalidades +- Solicitudes de amistad +- Aceptar/rechazar solicitudes +- Compartir metas y métodos de pago + +### 5.10 Notificaciones +**Módulo**: `features/notifications` + +#### Funcionalidades +- Sistema de notificaciones en tiempo real +- WebSocket para push notifications +- Notificaciones automáticas para: + - Vencimiento de deudas + - Progreso de metas + - Exceso de presupuesto + - Sugerencias financieras +- Expiración automática de notificaciones +- Marcado de leído/no leído + +### 5.11 Reportes +**Módulo**: `features/reports` + +#### Tipos de Reportes Disponibles +```typescript +enum ReportType { + GOAL = "GOAL", + CONTRIBUTION = "CONTRIBUTION", + BUDGET = "BUDGET", + EXPENSE = "EXPENSE", + INCOME = "INCOME", + GOALS_BY_STATUS = "GOALS_BY_STATUS", + GOALS_BY_CATEGORY = "GOALS_BY_CATEGORY", + CONTRIBUTIONS_BY_GOAL = "CONTRIBUTIONS_BY_GOAL", + SAVINGS_COMPARISON = "SAVINGS_COMPARISON", + SAVINGS_SUMMARY = "SAVINGS_SUMMARY", +} +``` + +#### Formatos de Exportación +- **JSON**: Datos estructurados +- **PDF**: Documentos imprimibles +- **Excel**: Hojas de cálculo (.xlsx) +- **CSV**: Archivos de texto separados por comas + +#### Servicios de Generación +1. **ExcelService**: Genera reportes en formato Excel +2. **CSVService**: Genera reportes en formato CSV +3. **PDFService**: Genera reportes en formato PDF + +#### Características +- Reportes temporales con expiración automática +- Filtros avanzados (fecha, categoría, usuario) +- Cleanup automático con cron job +- Endpoints RESTful para generación y descarga + +#### Endpoints +``` +POST /reports/generate +GET /reports/:id +DELETE /reports/:id +``` + +### 5.12 Agentes de IA +**Módulo**: `features/ai-agents` + +#### Arquitectura del Sistema de Agentes + +##### Componentes Principales +1. **VoiceOrchestratorService**: Orquestador principal +2. **Agentes Especializados**: + - TransactionAgentService + - GoalAgentService + - BudgetAgentService + - ValidationAgentService + +##### Flujo de Procesamiento +``` +Audio Input + ↓ +Transcripción (OpenAI Whisper) + ↓ +Clasificación de Intención (LLM) + ↓ +Extracción de Datos (LLM) + ↓ +Procesamiento por Agente + ↓ +Validación + ↓ +Respuesta Estructurada +``` + +##### Intenciones Soportadas +```typescript +enum CommandIntent { + CREATE_TRANSACTION, + CREATE_GOAL, + CREATE_BUDGET, + UNKNOWN +} +``` + +##### Endpoint +``` +POST /voice-command +Content-Type: multipart/form-data +Body: audio file (audio/wav) + +Response: +{ + "success": true, + "intent": "CREATE_TRANSACTION", + "extractedData": {...}, + "confidence": 0.95, + "message": "...", + "validationErrors": [], + "suggestedCorrections": {} +} +``` + +##### Ejemplos de Comandos de Voz +**Transacciones**: +- "Gasté 25 dólares en comida" +- "Recibí 500 dólares de mi trabajo" + +**Metas**: +- "Quiero ahorrar 1000 dólares para vacaciones hasta diciembre" + +**Presupuestos**: +- "Crear un presupuesto de 300 dólares para comida este mes" + +### 5.13 Email +**Módulo**: `features/email` + +#### Funcionalidades +- Envío de emails con Brevo +- Notificaciones por correo +- Recuperación de contraseña + +--- + +## 6. Tareas Pendientes y Bugs + +### 6.1 Dashboard +**Estado**: Problemas críticos + +#### Issues Identificados +1. **Cálculo de totales incorrecto** + - Los totales de ingresos y gastos no están considerando todas las transacciones + - Requiere revisión de la query de agregación + +2. **Gastos por categoría no se muestran** + - La visualización de distribución por categorías no funciona + - Posible problema en el endpoint o en el frontend + +3. **Falta de gráficos** + - Necesita implementación de visualizaciones: + - Gráfico de ingresos vs gastos + - Distribución por categorías (pie chart) + - Evolución temporal (line chart) + - Progreso de metas + +### 6.2 Reportes +**Estado**: Funcionalidad incompleta + +#### Issues Identificados +1. **Información incompleta** + - Reportes solo muestran títulos + - Faltan detalles de transacciones + - Algunos campos muestran "undefined" + +2. **Traducciones faltantes** + - Texto sin internacionalización (i18n) + - Mezcla de español e inglés + +3. **Tipos de reportes limitados** + - Ampliar tipos disponibles (ver ReportType enum) + - Agregar más filtros y opciones de agrupación + +4. **Falta de gráficos en reportes** + - Los reportes PDF/Excel no incluyen visualizaciones + - Integrar los mismos gráficos del dashboard + +5. **Errores intermitentes** + - Reportes fallan aleatoriamente en generación + - Requiere debugging y manejo de errores mejorado + +### 6.3 Información de Usuario +**Estado**: Funcionalidad básica faltante + +#### Tareas Pendientes +1. **Edición de perfil** + - Actualizar nombre de usuario + - Cambio de contraseña + - Actualizar email + - Foto de perfil + +2. **Preferencias de usuario** + - Moneda predeterminada + - Idioma + - Zona horaria + - Notificaciones + +### 6.4 Integración IA +**Estado**: Funcionalidad crítica no operativa + +#### Issues Identificados +1. **Asistente inteligente no funciona** + - El sistema de comandos de voz no responde + - Posible problema con la API de OpenAI + - Revisar integración con LangChain + +2. **Funcionalidad de recomendaciones faltante** + - Implementar sugerencias personalizadas basadas en: + - Patrones de gasto + - Metas de ahorro + - Comportamiento histórico + - Usar agentes de IA para análisis predictivo + +3. **Creación de entidades vía agente virtual** + - Revisar flujo de creación de: + - Metas de ahorro + - Presupuestos + - Transacciones + - Validar integraciones con repositorios + +### 6.5 Bugs Críticos + +#### Bug #1: Selección de categoría "Otros" +**Módulo**: Transacciones +**Descripción**: Al registrar una transacción, seleccionar la categoría "Otros" falla +**Posibles Causas**: +- Validación incorrecta en el formulario +- ID de categoría "Otros" no existe en BD +- Problema en el componente de selección + +#### Bug #2: Cálculo erróneo de totales en Dashboard +**Módulo**: Dashboard +**Descripción**: Los totales no reflejan todos los ingresos y gastos +**Investigar**: +- Query de agregación en el backend +- Filtros de fecha aplicados +- Transacciones excluidas (deudas, contribuciones a metas) + +#### Bug #3: Error en generación de reportes +**Módulo**: Reportes +**Descripción**: Reportes fallan intermitentemente +**Investigar**: +- Logs de errores específicos +- Timeouts en queries grandes +- Problemas con generación de PDF/Excel +- Validación de datos antes de generar reporte + +#### Bug #4: Notificaciones duplicadas de metas +**Módulo**: Notificaciones +**Descripción**: Las advertencias de metas de ahorro se muestran múltiples veces +**Investigar**: +- Lógica del cron job de notificaciones +- Verificación de notificaciones existentes +- Sistema de deduplicación + +--- + +## 7. Flujos de Datos + +### 7.1 Flujo de Creación de Transacción + +``` +Usuario → Frontend + ↓ +POST /transactions + ↓ +TransactionController + ↓ +CreateTransactionUseCase + ↓ +TransactionService + ↓ +PgTransactionRepository + ↓ +Database (transactions table) + ↓ +[Si está vinculado a Budget] + → UpdateBudgetUseCase + → Verificar límite + → Crear notificación si excede + ↓ +[Si está vinculado a Goal] + → CreateGoalContributionUseCase + → Actualizar current_amount + ↓ +Response → Frontend +``` + +### 7.2 Flujo de Creación de Meta + +``` +Usuario → Frontend + ↓ +POST /goals + ↓ +GoalController + ↓ +CreateGoalUseCase + ↓ +GoalService + ├→ Calcular contribution_amount automático + │ basado en: + │ - target_amount + │ - end_date + │ - contribution_frequency + ↓ +PgGoalRepository + ↓ +Database (goals table) + ↓ +[Opcional] GoalContributionScheduleService + → Generar programación de contribuciones + → Crear registros en goal_contribution_schedule + ↓ +Response → Frontend +``` + +### 7.3 Flujo de Comando de Voz + +``` +Usuario graba audio → Frontend + ↓ +POST /voice-command (multipart/form-data) + ↓ +VoiceCommandController + ↓ +VoiceOrchestratorService + ├→ 1. Transcribir audio (Whisper API) + ├→ 2. Clasificar intención (LLM) + ├→ 3. Extraer entidades (LLM) + │ - amount, description, category, etc. + ↓ +[Routing basado en intent] + ├→ CREATE_TRANSACTION → TransactionAgentService + ├→ CREATE_GOAL → GoalAgentService + ├→ CREATE_BUDGET → BudgetAgentService + ↓ +ValidationAgentService + ├→ Validar datos extraídos + ├→ Corregir inconsistencias + ├→ Completar datos faltantes + ↓ +Response con extractedData → Frontend + ↓ +Frontend muestra formulario pre-llenado + ↓ +Usuario confirma o edita + ↓ +Crear entidad normalmente +``` + +### 7.4 Flujo de Generación de Reportes + +``` +Usuario solicita reporte → Frontend + ↓ +POST /reports/generate +Body: { + type: ReportType, + format: ReportFormat, + filters: {...} +} + ↓ +ReportController + ↓ +ReportServiceImpl + ├→ Validar tipo y filtros + ├→ Ejecutar query específico según type: + │ ├→ GOAL → PgGoalRepository.findByFilters() + │ ├→ EXPENSE → PgTransactionRepository.findExpenses() + │ ├→ SAVINGS_SUMMARY → Múltiples queries agregadas + │ └→ ... + ↓ +Generar datos estructurados (JSON) + ↓ +PgReportRepository.create() + → Guardar en tabla reports + → Calcular expiresAt (24-72 horas) + ↓ +Response: { id: uuid, type, format, ... } + ↓ +Frontend redirige a GET /reports/:id + ↓ +ReportController.getReport() + ├→ PgReportRepository.findById() + ├→ Verificar expiración + │ + ├→ [Si format === JSON] + │ → Return c.json(report.data) + │ + ├→ [Si format === PDF] + │ → PDFService.generatePDF(report) + │ → Return Response(buffer, headers) + │ + ├→ [Si format === EXCEL] + │ → ExcelService.generateExcel(report) + │ → Return Response(buffer, headers) + │ + └→ [Si format === CSV] + → CSVService.generateCSV(report) + → Return Response(buffer, headers) + ↓ +Download automático en navegador +``` + +### 7.5 Flujo de Notificaciones + +``` +[Trigger: Cron Job o Evento] + ↓ +NotificationService + ├→ Tipo: BUDGET_EXCEEDED + │ └→ BudgetService detecta exceso + │ + ├→ Tipo: GOAL_WARNING + │ └→ GoalService detecta atraso + │ + ├→ Tipo: DEBT_DUE + │ └→ DebtService detecta vencimiento próximo + │ + └→ Tipo: FINANCIAL_SUGGESTION + └→ AIAgentService genera sugerencia + ↓ +PgNotificationRepository.create({ + user_id, + type, + title, + message, + data: {...}, + expires_at +}) + ↓ +Database (notifications table) + ↓ +[WebSocket Push] + → NotificationSocket + → Enviar a cliente conectado + → Frontend muestra notificación + ↓ +[Email opcional] + → EmailService + → Brevo API + → Enviar correo +``` + +--- + +## 8. Configuración y Deployment + +### 8.1 Variables de Entorno + +**Archivo**: `.env` + +```bash +# Server +PORT=3000 +NODE_ENV=development + +# Database +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_NAME=database +DATABASE_USERNAME=your_username +DATABASE_PASSWORD=your_password + +# JWT +JWT_SECRET=your_jwt_secret_key +JWT_EXPIRATION=7d + +# OpenAI (para agentes de IA) +OPENAI_API_KEY=your_openai_api_key + +# Brevo (Email) +BREVO_API_KEY=your_brevo_api_key +BREVO_SENDER_EMAIL=noreply@foppyai.com +BREVO_SENDER_NAME=FoppyAI + +# Frontend URL (CORS) +FRONTEND_URL=http://localhost:3001 +``` + +### 8.2 Docker + +**Archivo**: `docker-compose.yaml` + +El proyecto incluye configuración Docker para ejecutar PostgreSQL: + +```bash +# Iniciar base de datos +docker compose up -d + +# Detener +docker compose down + +# Ver logs +docker compose logs -f +``` + +### 8.3 Migraciones de Base de Datos + +#### Generar Migraciones +```bash +bun drizzle-kit generate +``` + +#### Ejecutar Migraciones +```bash +bun drizzle-kit migrate +``` + +#### Drizzle Studio (GUI) +```bash +bun drizzle-kit studio +``` + +### 8.4 Seed de Datos + +#### Limpiar Base de Datos +```bash +bun run db:clean +``` + +#### Seed de Categorías +```bash +bun run db:seed +``` + +#### Refresh Completo +```bash +bun run db:refresh +``` + +### 8.5 Desarrollo + +#### Iniciar Servidor de Desarrollo +```bash +bun install +bun run dev +``` + +El servidor se iniciará en `http://localhost:3000` con hot reload habilitado. + +#### Documentación API +Una vez iniciado el servidor, la documentación OpenAPI está disponible en: +``` +http://localhost:3000/reference +``` + +### 8.6 Producción + +#### Build +```bash +bun run build +``` + +#### Deployment +El proyecto incluye `Dockerfile` y configuración para deployment con Docker: + +```bash +# Build imagen +docker build -t foppyai-backend . + +# Run container +docker run -p 3000:3000 --env-file .env foppyai-backend +``` + +--- + +## 9. Mejoras Técnicas Sugeridas + +### 9.1 Prioridad Alta + +1. **Testing** + - Implementar pruebas unitarias (casos de uso) + - Pruebas de integración (repositorios) + - E2E tests para flujos críticos + - Coverage mínimo: 70% + +2. **Manejo de Errores** + - Middleware centralizado de errores + - Códigos de error consistentes + - Logging estructurado + - Alertas para errores críticos + +3. **Validación de Datos** + - Validación exhaustiva con Zod en todos los endpoints + - DTOs bien definidos + - Sanitización de inputs + +4. **Seguridad** + - Rate limiting + - Helmet middleware + - Validación de CSRF + - Auditoría de accesos + +### 9.2 Prioridad Media + +1. **Performance** + - Implementar caché (Redis) + - Paginación en todos los listados + - Índices de base de datos optimizados + - Query optimization + +2. **Documentación** + - Ampliar documentación OpenAPI + - Guías de integración + - Ejemplos de uso de API + - Diagramas de arquitectura + +3. **Monitoreo** + - APM (Application Performance Monitoring) + - Health checks + - Métricas de negocio + - Dashboards operacionales + +### 9.3 Prioridad Baja + +1. **Internacionalización** + - Sistema i18n completo + - Soporte multi-idioma + - Formatos de fecha/moneda localizados + +2. **Features Avanzadas** + - Exportación de datos completa + - Importación desde archivos CSV + - Integraciones con bancos (Open Banking) + - Aplicación móvil nativa + +--- + +## 10. Patrones y Mejores Prácticas + +### 10.1 Patrones Implementados + +1. **Singleton**: DatabaseConnection, Repositorios +2. **Repository Pattern**: Abstracción de acceso a datos +3. **Dependency Injection**: Servicios reciben dependencias +4. **Factory Pattern**: createApp, createRouter +5. **Strategy Pattern**: Agentes especializados de IA + +### 10.2 Convenciones de Código + +#### Nomenclatura +- **Archivos**: kebab-case (user-controller.ts) +- **Clases**: PascalCase (UserService) +- **Funciones**: camelCase (createUser) +- **Constantes**: UPPER_SNAKE_CASE (MAX_RETRIES) + +#### Estructura de Archivos +``` +feature/ +├── application/ +│ ├── dtos/ +│ │ └── create-xxx.dto.ts +│ └── services/ +│ └── xxx.service.ts +├── domain/ +│ ├── entities/ +│ │ └── xxx.entity.ts +│ └── ports/ +│ └── xxx.repository.interface.ts +└── infrastructure/ + ├── adapters/ + │ └── xxx.repository.ts + └── controllers/ + ├── xxx.controller.ts + └── xxx.routes.ts +``` + +#### Respuestas API Consistentes +```typescript +// Success +{ + "success": true, + "data": {...}, + "message": "Operation successful" +} + +// Error +{ + "success": false, + "data": null, + "message": "Error description" +} +``` + +--- + +## 11. Conclusiones y Próximos Pasos + +### Estado Actual +FoppyAI es un sistema de gestión financiera personal robusto con características avanzadas de IA. La arquitectura hexagonal proporciona una base sólida para el crecimiento futuro. + +### Áreas de Enfoque Inmediato + +1. **Resolver bugs críticos** (Dashboard, Reportes, Categorías) +2. **Completar funcionalidad de reportes** +3. **Reparar integración de IA** +4. **Implementar edición de perfil de usuario** +5. **Agregar tests automatizados** + +### Roadmap Sugerido + +**Q1 2025** +- Corrección de bugs críticos +- Completar reportes con gráficos +- Dashboard funcional completo +- Testing básico (>50% coverage) + +**Q2 2025** +- Sistema de IA completamente operativo +- Recomendaciones personalizadas +- Aplicación móvil (React Native) +- Integraciones bancarias + +**Q3 2025** +- Multi-tenancy +- Planes de suscripción +- Features premium +- Escalabilidad horizontal + +--- + +## Apéndices + +### A. Endpoints de API (Resumen) + +#### Autenticación +- POST /auth/register +- POST /auth/login +- POST /auth/forgot-password +- POST /auth/reset-password + +#### Usuarios +- GET /users/profile +- PUT /users/profile + +#### Transacciones +- POST /transactions +- GET /transactions +- GET /transactions/:id +- PUT /transactions/:id +- DELETE /transactions/:id + +#### Metas +- POST /goals +- GET /goals +- GET /goals/:id +- PUT /goals/:id +- DELETE /goals/:id +- POST /goals/:id/contributions + +#### Reportes +- POST /reports/generate +- GET /reports/:id +- DELETE /reports/:id + +#### IA +- POST /voice-command + +### B. Referencias + +- [Hono.js Documentation](https://hono.dev/) +- [Drizzle ORM](https://orm.drizzle.team/) +- [Bun Documentation](https://bun.sh/docs) +- [LangChain Documentation](https://js.langchain.com/) +- [OpenAPI Specification](https://swagger.io/specification/) + +--- + +**Última Actualización**: Octubre 2025 +**Versión**: 1.0.0 +**Mantenedor**: Equipo FoppyAI \ No newline at end of file diff --git a/documentation/PROJECT_DOC.md b/documentation/PROJECT_DOC.md new file mode 100644 index 0000000..6ee4089 --- /dev/null +++ b/documentation/PROJECT_DOC.md @@ -0,0 +1,54 @@ +📄 Documento 1: Backend Documentation +Incluye: + +Arquitectura hexagonal del backend +14 módulos de features completos +Sistema de agentes de IA con comandos de voz +Base de datos con 13 tablas +Sistema de reportes (PDF, Excel, CSV) +Trabajos cron automatizados +Configuración Docker y deployment + +📄 Documento 2: Frontend Documentation +Incluye: + +Arquitectura Next.js 14 con App Router +Stack completo (React Query, shadcn/ui, NextAuth) +Sistema de autenticación con JWT +12 módulos de features implementados +Componentes UI reutilizables +Integración con backend vía Axios +Sistema de notificaciones en tiempo real +Comandos de voz con IA +Rutas y navegación +Testing y deployment + +Resumen Ejecutivo +FoppyAI es un sistema completo de gestión de finanzas personales con las siguientes características destacadas: +✨ Características Principales + +Dashboard Financiero con resumen de ingresos/gastos/balance +Metas de Ahorro con contribuciones automáticas +Presupuestos por categoría con alertas +Gestión de Deudas con tracking de pagos +Transacciones completas (ingresos/gastos) +Reportes en múltiples formatos (PDF, Excel, CSV, JSON) +Comandos de Voz con IA para crear transacciones/metas/presupuestos +Notificaciones en Tiempo Real vía WebSocket +Tema Claro/Oscuro +Autenticación Segura con JWT + +📊 Estado del Proyecto + +Backend: Funcional con arquitectura hexagonal +Frontend: Funcional con Next.js 14 y UI moderna +IA: Implementado pero con issues +Testing: Configurado pero con baja cobertura + +🐛 Bugs Críticos Identificados + +Dashboard muestra totales incorrectos +Categoría "Otros" falla en transacciones +Reportes muestran información incompleta +Notificaciones de metas se duplican +Asistente de IA no funciona correctamente \ No newline at end of file diff --git a/drizzle.config.ts b/drizzle.config.ts index 0a9829c..b62f4f7 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,16 +1,58 @@ -import env from "@/env"; import type { Config } from "drizzle-kit"; +import { config } from "dotenv"; +import { expand } from "dotenv-expand"; +import path from "node:path"; + +// Load environment variables based on NODE_ENV +const envFile = + process.env.NODE_ENV === "test" + ? ".env.test" + : process.env.NODE_ENV === "production" + ? ".env.docker" + : ".env"; + +expand(config({ path: path.resolve(process.cwd(), envFile) })); + +// Parse DATABASE_URL if available to get the actual database name +let dbHost = "localhost"; +let dbPort = 5432; +let dbUser = process.env.DATABASE_USERNAME || "postgres"; +let dbPassword = process.env.DATABASE_PASSWORD || "postgres"; +let dbName = process.env.DATABASE_NAME || "fopymes"; + +if (process.env.DATABASE_URL) { + try { + const url = new URL(process.env.DATABASE_URL); + dbHost = url.hostname; + dbPort = url.port ? parseInt(url.port) : 5432; + dbUser = url.username || dbUser; + dbPassword = url.password || dbPassword; + dbName = url.pathname.replace("/", "") || dbName; + } catch (error) { + console.error("Error parsing DATABASE_URL:", error); + } +} + +const isTestEnv = process.env.NODE_ENV === "test"; +const connectionString = + (isTestEnv && process.env.TEST_DATABASE_URL) || env.DATABASE_URL; + +if (isTestEnv && !process.env.TEST_DATABASE_URL) { + console.warn( + "⚠️ TEST_DATABASE_URL no está configurado. Usando DATABASE_URL por defecto." + ); +} export default { schema: "./src/core/infrastructure/database/schema.ts", out: "./drizzle", dialect: "postgresql", dbCredentials: { - host: process.env.NODE_ENV === "production" ? "db" : "localhost", - port: Number(env.NODE_ENV === "production" ? "5432" : env.DATABASE_PORT), - user: env.DATABASE_USERNAME, - password: env.DATABASE_PASSWORD, - database: env.DATABASE_NAME, + host: process.env.NODE_ENV === "production" ? "db" : dbHost, + port: process.env.NODE_ENV === "production" ? 5432 : dbPort, + user: dbUser, + password: dbPassword, + database: dbName, ssl: false, }, } satisfies Config; diff --git a/drizzle/0008_brown_stark_industries.sql b/drizzle/0008_brown_stark_industries.sql new file mode 100644 index 0000000..d07d0ce --- /dev/null +++ b/drizzle/0008_brown_stark_industries.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "recommendations_enabled" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/0008_empty_guardsmen.sql b/drizzle/0008_empty_guardsmen.sql new file mode 100644 index 0000000..d423398 --- /dev/null +++ b/drizzle/0008_empty_guardsmen.sql @@ -0,0 +1,29 @@ +CREATE TABLE "plans" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "duration_days" integer NOT NULL, + "price" numeric(10, 2) NOT NULL, + "frequency" varchar NOT NULL, + "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "plans_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE "subscriptions" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "plan_id" integer NOT NULL, + "frequency" varchar NOT NULL, + "start_date" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + "end_date" timestamp NOT NULL, + "retirement_date" timestamp, + "active" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "sub_user_idx" ON "subscriptions" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "sub_plan_idx" ON "subscriptions" USING btree ("plan_id");--> statement-breakpoint +CREATE INDEX "sub_active_idx" ON "subscriptions" USING btree ("active"); \ No newline at end of file diff --git a/drizzle/0008_subscriptions.sql b/drizzle/0008_subscriptions.sql new file mode 100644 index 0000000..3d0a568 --- /dev/null +++ b/drizzle/0008_subscriptions.sql @@ -0,0 +1,36 @@ +CREATE TABLE IF NOT EXISTS "plans" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "duration_days" integer NOT NULL, + "price" numeric(10, 2) NOT NULL, + "frequency" varchar NOT NULL, + "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "plans_name_unique" UNIQUE("name") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "subscriptions" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "plan_id" integer NOT NULL, + "frequency" varchar NOT NULL, + "start_date" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + "end_date" timestamp NOT NULL, + "retirement_date" timestamp, + "active" boolean DEFAULT true NOT NULL, + "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updated_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "sub_user_idx" ON "subscriptions" ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "sub_plan_idx" ON "subscriptions" ("plan_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "sub_active_idx" ON "subscriptions" ("active");--> statement-breakpoint +ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +INSERT INTO "plans" ("name", "duration_days", "price", "frequency") VALUES +('demo', 15, 0.00, 'one-time'), +('lite', 30, 9.99, 'monthly'), +('lite', 365, 99.99, 'yearly'), +('plus', 30, 19.99, 'monthly'), +('plus', 365, 199.99, 'yearly'); + diff --git a/drizzle/0009_overjoyed_dust.sql b/drizzle/0009_overjoyed_dust.sql new file mode 100644 index 0000000..34255f7 --- /dev/null +++ b/drizzle/0009_overjoyed_dust.sql @@ -0,0 +1,23 @@ +CREATE TABLE "recommendations" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "type" varchar(50) NOT NULL, + "priority" varchar(20) NOT NULL, + "title" varchar(255) NOT NULL, + "description" text NOT NULL, + "data" jsonb, + "actionable" boolean DEFAULT false NOT NULL, + "actions" jsonb, + "status" varchar(20) DEFAULT 'PENDING' NOT NULL, + "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expires_at" timestamp, + "viewed_at" timestamp, + "dismissed_at" timestamp, + "acted_at" timestamp +); +--> statement-breakpoint +ALTER TABLE "recommendations" ADD CONSTRAINT "recommendations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "rec_user_idx" ON "recommendations" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "rec_status_idx" ON "recommendations" USING btree ("status");--> statement-breakpoint +CREATE INDEX "rec_type_idx" ON "recommendations" USING btree ("type");--> statement-breakpoint +CREATE INDEX "rec_created_idx" ON "recommendations" USING btree ("created_at"); \ No newline at end of file diff --git a/drizzle/0009_silky_jamie_braddock.sql b/drizzle/0009_silky_jamie_braddock.sql new file mode 100644 index 0000000..58f7ebd --- /dev/null +++ b/drizzle/0009_silky_jamie_braddock.sql @@ -0,0 +1 @@ +ALTER TABLE "plans" DROP CONSTRAINT "plans_name_unique"; \ No newline at end of file diff --git a/drizzle/0010_sharp_morgan_stark.sql b/drizzle/0010_sharp_morgan_stark.sql new file mode 100644 index 0000000..f9de867 --- /dev/null +++ b/drizzle/0010_sharp_morgan_stark.sql @@ -0,0 +1,2 @@ +ALTER TABLE "plans" ADD COLUMN "description" text;--> statement-breakpoint +ALTER TABLE "plans" ADD COLUMN "features" jsonb; \ No newline at end of file diff --git a/drizzle/0011_true_leper_queen.sql b/drizzle/0011_true_leper_queen.sql new file mode 100644 index 0000000..ed9d429 --- /dev/null +++ b/drizzle/0011_true_leper_queen.sql @@ -0,0 +1,12 @@ +-- Only add the recommendations_enabled column if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'users' + AND column_name = 'recommendations_enabled' + ) THEN + ALTER TABLE "users" ADD COLUMN "recommendations_enabled" boolean DEFAULT false NOT NULL; + END IF; +END $$; \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..a7d3480 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,2013 @@ +{ + "id": "fe141e79-c873-4a0c-8c00-d6d977b1e32c", + "prevId": "0af304cf-d2cb-4241-afc6-947d48b98414", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.budgets": { + "name": "budgets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "limit_amount": { + "name": "limit_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "current_amount": { + "name": "current_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "month": { + "name": "month", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "budget_user_idx": { + "name": "budget_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_category_idx": { + "name": "budget_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_month_idx": { + "name": "budget_month_idx", + "columns": [ + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budgets_user_id_users_id_fk": { + "name": "budgets_user_id_users_id_fk", + "tableFrom": "budgets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budgets_shared_user_id_users_id_fk": { + "name": "budgets_shared_user_id_users_id_fk", + "tableFrom": "budgets", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budgets_category_id_categories_id_fk": { + "name": "budgets_category_id_categories_id_fk", + "tableFrom": "budgets", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_name_unique": { + "name": "categories_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.debts": { + "name": "debts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_amount": { + "name": "original_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "pending_amount": { + "name": "pending_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "paid": { + "name": "paid", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "creditor_id": { + "name": "creditor_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "debts_user_idx": { + "name": "debts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "debts_creditor_idx": { + "name": "debts_creditor_idx", + "columns": [ + { + "expression": "creditor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "debts_due_date_idx": { + "name": "debts_due_date_idx", + "columns": [ + { + "expression": "due_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "debts_user_id_users_id_fk": { + "name": "debts_user_id_users_id_fk", + "tableFrom": "debts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "debts_creditor_id_users_id_fk": { + "name": "debts_creditor_id_users_id_fk", + "tableFrom": "debts", + "tableTo": "users", + "columnsFrom": [ + "creditor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "debts_category_id_categories_id_fk": { + "name": "debts_category_id_categories_id_fk", + "tableFrom": "debts", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.friends": { + "name": "friends", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "friend_id": { + "name": "friend_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "connection_date": { + "name": "connection_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "friends_user_idx": { + "name": "friends_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "friends_friend_idx": { + "name": "friends_friend_idx", + "columns": [ + { + "expression": "friend_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "friends_user_id_users_id_fk": { + "name": "friends_user_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "friends_friend_id_users_id_fk": { + "name": "friends_friend_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "friend_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goal_contribution_schedule": { + "name": "goal_contribution_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "contribution_id": { + "name": "contribution_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "gcs_goal_idx": { + "name": "gcs_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_user_idx": { + "name": "gcs_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_date_idx": { + "name": "gcs_date_idx", + "columns": [ + { + "expression": "scheduled_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_status_idx": { + "name": "gcs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goal_contribution_schedule_goal_id_goals_id_fk": { + "name": "goal_contribution_schedule_goal_id_goals_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contribution_schedule_user_id_users_id_fk": { + "name": "goal_contribution_schedule_user_id_users_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contribution_schedule_contribution_id_goal_contributions_id_fk": { + "name": "goal_contribution_schedule_contribution_id_goal_contributions_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "goal_contributions", + "columnsFrom": [ + "contribution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goal_contributions": { + "name": "goal_contributions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "gc_goal_idx": { + "name": "gc_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gc_user_idx": { + "name": "gc_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gc_date_idx": { + "name": "gc_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goal_contributions_goal_id_goals_id_fk": { + "name": "goal_contributions_goal_id_goals_id_fk", + "tableFrom": "goal_contributions", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contributions_user_id_users_id_fk": { + "name": "goal_contributions_user_id_users_id_fk", + "tableFrom": "goal_contributions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "target_amount": { + "name": "target_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "current_amount": { + "name": "current_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "contribution_frequency": { + "name": "contribution_frequency", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "contribution_amount": { + "name": "contribution_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "goals_user_idx": { + "name": "goals_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "goals_shared_user_idx": { + "name": "goals_shared_user_idx", + "columns": [ + { + "expression": "shared_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_user_id_users_id_fk": { + "name": "goals_user_id_users_id_fk", + "tableFrom": "goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_shared_user_id_users_id_fk": { + "name": "goals_shared_user_id_users_id_fk", + "tableFrom": "goals", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_category_id_categories_id_fk": { + "name": "goals_category_id_categories_id_fk", + "tableFrom": "goals", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "notif_user_idx": { + "name": "notif_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notif_read_idx": { + "name": "notif_read_idx", + "columns": [ + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notif_expires_idx": { + "name": "notif_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "last_four_digits": { + "name": "last_four_digits", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "pm_user_idx": { + "name": "pm_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pm_shared_user_idx": { + "name": "pm_shared_user_idx", + "columns": [ + { + "expression": "shared_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_user_id_users_id_fk": { + "name": "payment_methods_user_id_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payment_methods_shared_user_id_users_id_fk": { + "name": "payment_methods_shared_user_id_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plans": { + "name": "plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "duration_days": { + "name": "duration_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "plans_name_unique": { + "name": "plans_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "reports_user_id_users_id_fk": { + "name": "reports_user_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_transactions": { + "name": "scheduled_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "frequency": { + "name": "frequency", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "next_execution_date": { + "name": "next_execution_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "st_user_idx": { + "name": "st_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "st_next_date_idx": { + "name": "st_next_date_idx", + "columns": [ + { + "expression": "next_execution_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_transactions_user_id_users_id_fk": { + "name": "scheduled_transactions_user_id_users_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scheduled_transactions_category_id_categories_id_fk": { + "name": "scheduled_transactions_category_id_categories_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scheduled_transactions_payment_method_id_payment_methods_id_fk": { + "name": "scheduled_transactions_payment_method_id_payment_methods_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "payment_methods", + "columnsFrom": [ + "payment_method_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "retirement_date": { + "name": "retirement_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "sub_user_idx": { + "name": "sub_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sub_plan_idx": { + "name": "sub_plan_idx", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sub_active_idx": { + "name": "sub_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_users_id_fk": { + "name": "subscriptions_user_id_users_id_fk", + "tableFrom": "subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "subscriptions_plan_id_plans_id_fk": { + "name": "subscriptions_plan_id_plans_id_fk", + "tableFrom": "subscriptions", + "tableTo": "plans", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "scheduled_transaction_id": { + "name": "scheduled_transaction_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "debt_id": { + "name": "debt_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "contribution_id": { + "name": "contribution_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "budget_id": { + "name": "budget_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "tx_user_idx": { + "name": "tx_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_date_idx": { + "name": "tx_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_category_idx": { + "name": "tx_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_budget_idx": { + "name": "tx_budget_idx", + "columns": [ + { + "expression": "budget_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactions_user_id_users_id_fk": { + "name": "transactions_user_id_users_id_fk", + "tableFrom": "transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_category_id_categories_id_fk": { + "name": "transactions_category_id_categories_id_fk", + "tableFrom": "transactions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_payment_method_id_payment_methods_id_fk": { + "name": "transactions_payment_method_id_payment_methods_id_fk", + "tableFrom": "transactions", + "tableTo": "payment_methods", + "columnsFrom": [ + "payment_method_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_debt_id_debts_id_fk": { + "name": "transactions_debt_id_debts_id_fk", + "tableFrom": "transactions", + "tableTo": "debts", + "columnsFrom": [ + "debt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_contribution_id_goal_contributions_id_fk": { + "name": "transactions_contribution_id_goal_contributions_id_fk", + "tableFrom": "transactions", + "tableTo": "goal_contributions", + "columnsFrom": [ + "contribution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_budget_id_budgets_id_fk": { + "name": "transactions_budget_id_budgets_id_fk", + "tableFrom": "transactions", + "tableTo": "budgets", + "columnsFrom": [ + "budget_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "registration_date": { + "name": "registration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "recovery_token": { + "name": "recovery_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "recovery_token_expires": { + "name": "recovery_token_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "recommendations_enabled": { + "name": "recommendations_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..ce9eadd --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1998 @@ +{ + "id": "34b504b0-ea8a-419f-b0b7-d05b982cbae9", + "prevId": "fe141e79-c873-4a0c-8c00-d6d977b1e32c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.budgets": { + "name": "budgets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "limit_amount": { + "name": "limit_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "current_amount": { + "name": "current_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "month": { + "name": "month", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "budget_user_idx": { + "name": "budget_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_category_idx": { + "name": "budget_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_month_idx": { + "name": "budget_month_idx", + "columns": [ + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budgets_user_id_users_id_fk": { + "name": "budgets_user_id_users_id_fk", + "tableFrom": "budgets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budgets_shared_user_id_users_id_fk": { + "name": "budgets_shared_user_id_users_id_fk", + "tableFrom": "budgets", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budgets_category_id_categories_id_fk": { + "name": "budgets_category_id_categories_id_fk", + "tableFrom": "budgets", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_name_unique": { + "name": "categories_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.debts": { + "name": "debts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_amount": { + "name": "original_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "pending_amount": { + "name": "pending_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "paid": { + "name": "paid", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "creditor_id": { + "name": "creditor_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "debts_user_idx": { + "name": "debts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "debts_creditor_idx": { + "name": "debts_creditor_idx", + "columns": [ + { + "expression": "creditor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "debts_due_date_idx": { + "name": "debts_due_date_idx", + "columns": [ + { + "expression": "due_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "debts_user_id_users_id_fk": { + "name": "debts_user_id_users_id_fk", + "tableFrom": "debts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "debts_creditor_id_users_id_fk": { + "name": "debts_creditor_id_users_id_fk", + "tableFrom": "debts", + "tableTo": "users", + "columnsFrom": [ + "creditor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "debts_category_id_categories_id_fk": { + "name": "debts_category_id_categories_id_fk", + "tableFrom": "debts", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.friends": { + "name": "friends", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "friend_id": { + "name": "friend_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "connection_date": { + "name": "connection_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "friends_user_idx": { + "name": "friends_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "friends_friend_idx": { + "name": "friends_friend_idx", + "columns": [ + { + "expression": "friend_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "friends_user_id_users_id_fk": { + "name": "friends_user_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "friends_friend_id_users_id_fk": { + "name": "friends_friend_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "friend_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goal_contribution_schedule": { + "name": "goal_contribution_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "contribution_id": { + "name": "contribution_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "gcs_goal_idx": { + "name": "gcs_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_user_idx": { + "name": "gcs_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_date_idx": { + "name": "gcs_date_idx", + "columns": [ + { + "expression": "scheduled_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_status_idx": { + "name": "gcs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goal_contribution_schedule_goal_id_goals_id_fk": { + "name": "goal_contribution_schedule_goal_id_goals_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contribution_schedule_user_id_users_id_fk": { + "name": "goal_contribution_schedule_user_id_users_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contribution_schedule_contribution_id_goal_contributions_id_fk": { + "name": "goal_contribution_schedule_contribution_id_goal_contributions_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "goal_contributions", + "columnsFrom": [ + "contribution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goal_contributions": { + "name": "goal_contributions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "gc_goal_idx": { + "name": "gc_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gc_user_idx": { + "name": "gc_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gc_date_idx": { + "name": "gc_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goal_contributions_goal_id_goals_id_fk": { + "name": "goal_contributions_goal_id_goals_id_fk", + "tableFrom": "goal_contributions", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contributions_user_id_users_id_fk": { + "name": "goal_contributions_user_id_users_id_fk", + "tableFrom": "goal_contributions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "target_amount": { + "name": "target_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "current_amount": { + "name": "current_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "contribution_frequency": { + "name": "contribution_frequency", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "contribution_amount": { + "name": "contribution_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "goals_user_idx": { + "name": "goals_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "goals_shared_user_idx": { + "name": "goals_shared_user_idx", + "columns": [ + { + "expression": "shared_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_user_id_users_id_fk": { + "name": "goals_user_id_users_id_fk", + "tableFrom": "goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_shared_user_id_users_id_fk": { + "name": "goals_shared_user_id_users_id_fk", + "tableFrom": "goals", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_category_id_categories_id_fk": { + "name": "goals_category_id_categories_id_fk", + "tableFrom": "goals", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "notif_user_idx": { + "name": "notif_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notif_read_idx": { + "name": "notif_read_idx", + "columns": [ + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notif_expires_idx": { + "name": "notif_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "last_four_digits": { + "name": "last_four_digits", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "pm_user_idx": { + "name": "pm_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pm_shared_user_idx": { + "name": "pm_shared_user_idx", + "columns": [ + { + "expression": "shared_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_user_id_users_id_fk": { + "name": "payment_methods_user_id_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payment_methods_shared_user_id_users_id_fk": { + "name": "payment_methods_shared_user_id_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plans": { + "name": "plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "duration_days": { + "name": "duration_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "reports_user_id_users_id_fk": { + "name": "reports_user_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_transactions": { + "name": "scheduled_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "frequency": { + "name": "frequency", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "next_execution_date": { + "name": "next_execution_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "st_user_idx": { + "name": "st_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "st_next_date_idx": { + "name": "st_next_date_idx", + "columns": [ + { + "expression": "next_execution_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_transactions_user_id_users_id_fk": { + "name": "scheduled_transactions_user_id_users_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scheduled_transactions_category_id_categories_id_fk": { + "name": "scheduled_transactions_category_id_categories_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scheduled_transactions_payment_method_id_payment_methods_id_fk": { + "name": "scheduled_transactions_payment_method_id_payment_methods_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "payment_methods", + "columnsFrom": [ + "payment_method_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "retirement_date": { + "name": "retirement_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "sub_user_idx": { + "name": "sub_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sub_plan_idx": { + "name": "sub_plan_idx", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sub_active_idx": { + "name": "sub_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_users_id_fk": { + "name": "subscriptions_user_id_users_id_fk", + "tableFrom": "subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "subscriptions_plan_id_plans_id_fk": { + "name": "subscriptions_plan_id_plans_id_fk", + "tableFrom": "subscriptions", + "tableTo": "plans", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "scheduled_transaction_id": { + "name": "scheduled_transaction_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "debt_id": { + "name": "debt_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "contribution_id": { + "name": "contribution_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "budget_id": { + "name": "budget_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "tx_user_idx": { + "name": "tx_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_date_idx": { + "name": "tx_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_category_idx": { + "name": "tx_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_budget_idx": { + "name": "tx_budget_idx", + "columns": [ + { + "expression": "budget_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactions_user_id_users_id_fk": { + "name": "transactions_user_id_users_id_fk", + "tableFrom": "transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_category_id_categories_id_fk": { + "name": "transactions_category_id_categories_id_fk", + "tableFrom": "transactions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_payment_method_id_payment_methods_id_fk": { + "name": "transactions_payment_method_id_payment_methods_id_fk", + "tableFrom": "transactions", + "tableTo": "payment_methods", + "columnsFrom": [ + "payment_method_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_debt_id_debts_id_fk": { + "name": "transactions_debt_id_debts_id_fk", + "tableFrom": "transactions", + "tableTo": "debts", + "columnsFrom": [ + "debt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_contribution_id_goal_contributions_id_fk": { + "name": "transactions_contribution_id_goal_contributions_id_fk", + "tableFrom": "transactions", + "tableTo": "goal_contributions", + "columnsFrom": [ + "contribution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_budget_id_budgets_id_fk": { + "name": "transactions_budget_id_budgets_id_fk", + "tableFrom": "transactions", + "tableTo": "budgets", + "columnsFrom": [ + "budget_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "registration_date": { + "name": "registration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "recovery_token": { + "name": "recovery_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "recovery_token_expires": { + "name": "recovery_token_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0010_snapshot.json b/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..8e96644 --- /dev/null +++ b/drizzle/meta/0010_snapshot.json @@ -0,0 +1,2010 @@ +{ + "id": "3a64d546-50f6-46eb-a64d-57fc4127847e", + "prevId": "34b504b0-ea8a-419f-b0b7-d05b982cbae9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.budgets": { + "name": "budgets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "limit_amount": { + "name": "limit_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "current_amount": { + "name": "current_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "month": { + "name": "month", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "budget_user_idx": { + "name": "budget_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_category_idx": { + "name": "budget_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_month_idx": { + "name": "budget_month_idx", + "columns": [ + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budgets_user_id_users_id_fk": { + "name": "budgets_user_id_users_id_fk", + "tableFrom": "budgets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budgets_shared_user_id_users_id_fk": { + "name": "budgets_shared_user_id_users_id_fk", + "tableFrom": "budgets", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budgets_category_id_categories_id_fk": { + "name": "budgets_category_id_categories_id_fk", + "tableFrom": "budgets", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_name_unique": { + "name": "categories_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.debts": { + "name": "debts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_amount": { + "name": "original_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "pending_amount": { + "name": "pending_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "paid": { + "name": "paid", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "creditor_id": { + "name": "creditor_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "debts_user_idx": { + "name": "debts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "debts_creditor_idx": { + "name": "debts_creditor_idx", + "columns": [ + { + "expression": "creditor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "debts_due_date_idx": { + "name": "debts_due_date_idx", + "columns": [ + { + "expression": "due_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "debts_user_id_users_id_fk": { + "name": "debts_user_id_users_id_fk", + "tableFrom": "debts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "debts_creditor_id_users_id_fk": { + "name": "debts_creditor_id_users_id_fk", + "tableFrom": "debts", + "tableTo": "users", + "columnsFrom": [ + "creditor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "debts_category_id_categories_id_fk": { + "name": "debts_category_id_categories_id_fk", + "tableFrom": "debts", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.friends": { + "name": "friends", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "friend_id": { + "name": "friend_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "connection_date": { + "name": "connection_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "friends_user_idx": { + "name": "friends_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "friends_friend_idx": { + "name": "friends_friend_idx", + "columns": [ + { + "expression": "friend_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "friends_user_id_users_id_fk": { + "name": "friends_user_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "friends_friend_id_users_id_fk": { + "name": "friends_friend_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "friend_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goal_contribution_schedule": { + "name": "goal_contribution_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "contribution_id": { + "name": "contribution_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "gcs_goal_idx": { + "name": "gcs_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_user_idx": { + "name": "gcs_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_date_idx": { + "name": "gcs_date_idx", + "columns": [ + { + "expression": "scheduled_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_status_idx": { + "name": "gcs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goal_contribution_schedule_goal_id_goals_id_fk": { + "name": "goal_contribution_schedule_goal_id_goals_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contribution_schedule_user_id_users_id_fk": { + "name": "goal_contribution_schedule_user_id_users_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contribution_schedule_contribution_id_goal_contributions_id_fk": { + "name": "goal_contribution_schedule_contribution_id_goal_contributions_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "goal_contributions", + "columnsFrom": [ + "contribution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goal_contributions": { + "name": "goal_contributions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "gc_goal_idx": { + "name": "gc_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gc_user_idx": { + "name": "gc_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gc_date_idx": { + "name": "gc_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goal_contributions_goal_id_goals_id_fk": { + "name": "goal_contributions_goal_id_goals_id_fk", + "tableFrom": "goal_contributions", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contributions_user_id_users_id_fk": { + "name": "goal_contributions_user_id_users_id_fk", + "tableFrom": "goal_contributions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "target_amount": { + "name": "target_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "current_amount": { + "name": "current_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "contribution_frequency": { + "name": "contribution_frequency", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "contribution_amount": { + "name": "contribution_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "goals_user_idx": { + "name": "goals_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "goals_shared_user_idx": { + "name": "goals_shared_user_idx", + "columns": [ + { + "expression": "shared_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_user_id_users_id_fk": { + "name": "goals_user_id_users_id_fk", + "tableFrom": "goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_shared_user_id_users_id_fk": { + "name": "goals_shared_user_id_users_id_fk", + "tableFrom": "goals", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_category_id_categories_id_fk": { + "name": "goals_category_id_categories_id_fk", + "tableFrom": "goals", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "notif_user_idx": { + "name": "notif_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notif_read_idx": { + "name": "notif_read_idx", + "columns": [ + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notif_expires_idx": { + "name": "notif_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "last_four_digits": { + "name": "last_four_digits", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "pm_user_idx": { + "name": "pm_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pm_shared_user_idx": { + "name": "pm_shared_user_idx", + "columns": [ + { + "expression": "shared_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_user_id_users_id_fk": { + "name": "payment_methods_user_id_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payment_methods_shared_user_id_users_id_fk": { + "name": "payment_methods_shared_user_id_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plans": { + "name": "plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "duration_days": { + "name": "duration_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "reports_user_id_users_id_fk": { + "name": "reports_user_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_transactions": { + "name": "scheduled_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "frequency": { + "name": "frequency", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "next_execution_date": { + "name": "next_execution_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "st_user_idx": { + "name": "st_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "st_next_date_idx": { + "name": "st_next_date_idx", + "columns": [ + { + "expression": "next_execution_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_transactions_user_id_users_id_fk": { + "name": "scheduled_transactions_user_id_users_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scheduled_transactions_category_id_categories_id_fk": { + "name": "scheduled_transactions_category_id_categories_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scheduled_transactions_payment_method_id_payment_methods_id_fk": { + "name": "scheduled_transactions_payment_method_id_payment_methods_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "payment_methods", + "columnsFrom": [ + "payment_method_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "retirement_date": { + "name": "retirement_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "sub_user_idx": { + "name": "sub_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sub_plan_idx": { + "name": "sub_plan_idx", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sub_active_idx": { + "name": "sub_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_users_id_fk": { + "name": "subscriptions_user_id_users_id_fk", + "tableFrom": "subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "subscriptions_plan_id_plans_id_fk": { + "name": "subscriptions_plan_id_plans_id_fk", + "tableFrom": "subscriptions", + "tableTo": "plans", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "scheduled_transaction_id": { + "name": "scheduled_transaction_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "debt_id": { + "name": "debt_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "contribution_id": { + "name": "contribution_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "budget_id": { + "name": "budget_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "tx_user_idx": { + "name": "tx_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_date_idx": { + "name": "tx_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_category_idx": { + "name": "tx_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_budget_idx": { + "name": "tx_budget_idx", + "columns": [ + { + "expression": "budget_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactions_user_id_users_id_fk": { + "name": "transactions_user_id_users_id_fk", + "tableFrom": "transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_category_id_categories_id_fk": { + "name": "transactions_category_id_categories_id_fk", + "tableFrom": "transactions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_payment_method_id_payment_methods_id_fk": { + "name": "transactions_payment_method_id_payment_methods_id_fk", + "tableFrom": "transactions", + "tableTo": "payment_methods", + "columnsFrom": [ + "payment_method_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_debt_id_debts_id_fk": { + "name": "transactions_debt_id_debts_id_fk", + "tableFrom": "transactions", + "tableTo": "debts", + "columnsFrom": [ + "debt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_contribution_id_goal_contributions_id_fk": { + "name": "transactions_contribution_id_goal_contributions_id_fk", + "tableFrom": "transactions", + "tableTo": "goal_contributions", + "columnsFrom": [ + "contribution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_budget_id_budgets_id_fk": { + "name": "transactions_budget_id_budgets_id_fk", + "tableFrom": "transactions", + "tableTo": "budgets", + "columnsFrom": [ + "budget_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "registration_date": { + "name": "registration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "recovery_token": { + "name": "recovery_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "recovery_token_expires": { + "name": "recovery_token_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..40458a7 --- /dev/null +++ b/drizzle/meta/0011_snapshot.json @@ -0,0 +1,2198 @@ +{ + "id": "400a46d8-7072-4e7c-ab1f-e0d8d39b38a9", + "prevId": "3a64d546-50f6-46eb-a64d-57fc4127847e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.budgets": { + "name": "budgets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "limit_amount": { + "name": "limit_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "current_amount": { + "name": "current_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "month": { + "name": "month", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "budget_user_idx": { + "name": "budget_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_category_idx": { + "name": "budget_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_month_idx": { + "name": "budget_month_idx", + "columns": [ + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budgets_user_id_users_id_fk": { + "name": "budgets_user_id_users_id_fk", + "tableFrom": "budgets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budgets_shared_user_id_users_id_fk": { + "name": "budgets_shared_user_id_users_id_fk", + "tableFrom": "budgets", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budgets_category_id_categories_id_fk": { + "name": "budgets_category_id_categories_id_fk", + "tableFrom": "budgets", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_name_unique": { + "name": "categories_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.debts": { + "name": "debts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_amount": { + "name": "original_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "pending_amount": { + "name": "pending_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "paid": { + "name": "paid", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "creditor_id": { + "name": "creditor_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "debts_user_idx": { + "name": "debts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "debts_creditor_idx": { + "name": "debts_creditor_idx", + "columns": [ + { + "expression": "creditor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "debts_due_date_idx": { + "name": "debts_due_date_idx", + "columns": [ + { + "expression": "due_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "debts_user_id_users_id_fk": { + "name": "debts_user_id_users_id_fk", + "tableFrom": "debts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "debts_creditor_id_users_id_fk": { + "name": "debts_creditor_id_users_id_fk", + "tableFrom": "debts", + "tableTo": "users", + "columnsFrom": [ + "creditor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "debts_category_id_categories_id_fk": { + "name": "debts_category_id_categories_id_fk", + "tableFrom": "debts", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.friends": { + "name": "friends", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "friend_id": { + "name": "friend_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "connection_date": { + "name": "connection_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "friends_user_idx": { + "name": "friends_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "friends_friend_idx": { + "name": "friends_friend_idx", + "columns": [ + { + "expression": "friend_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "friends_user_id_users_id_fk": { + "name": "friends_user_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "friends_friend_id_users_id_fk": { + "name": "friends_friend_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "friend_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goal_contribution_schedule": { + "name": "goal_contribution_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "contribution_id": { + "name": "contribution_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "gcs_goal_idx": { + "name": "gcs_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_user_idx": { + "name": "gcs_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_date_idx": { + "name": "gcs_date_idx", + "columns": [ + { + "expression": "scheduled_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_status_idx": { + "name": "gcs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goal_contribution_schedule_goal_id_goals_id_fk": { + "name": "goal_contribution_schedule_goal_id_goals_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contribution_schedule_user_id_users_id_fk": { + "name": "goal_contribution_schedule_user_id_users_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contribution_schedule_contribution_id_goal_contributions_id_fk": { + "name": "goal_contribution_schedule_contribution_id_goal_contributions_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "goal_contributions", + "columnsFrom": [ + "contribution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goal_contributions": { + "name": "goal_contributions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "gc_goal_idx": { + "name": "gc_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gc_user_idx": { + "name": "gc_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gc_date_idx": { + "name": "gc_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goal_contributions_goal_id_goals_id_fk": { + "name": "goal_contributions_goal_id_goals_id_fk", + "tableFrom": "goal_contributions", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contributions_user_id_users_id_fk": { + "name": "goal_contributions_user_id_users_id_fk", + "tableFrom": "goal_contributions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "target_amount": { + "name": "target_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "current_amount": { + "name": "current_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "contribution_frequency": { + "name": "contribution_frequency", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "contribution_amount": { + "name": "contribution_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "goals_user_idx": { + "name": "goals_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "goals_shared_user_idx": { + "name": "goals_shared_user_idx", + "columns": [ + { + "expression": "shared_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_user_id_users_id_fk": { + "name": "goals_user_id_users_id_fk", + "tableFrom": "goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_shared_user_id_users_id_fk": { + "name": "goals_shared_user_id_users_id_fk", + "tableFrom": "goals", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_category_id_categories_id_fk": { + "name": "goals_category_id_categories_id_fk", + "tableFrom": "goals", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "notif_user_idx": { + "name": "notif_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notif_read_idx": { + "name": "notif_read_idx", + "columns": [ + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notif_expires_idx": { + "name": "notif_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "last_four_digits": { + "name": "last_four_digits", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "pm_user_idx": { + "name": "pm_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pm_shared_user_idx": { + "name": "pm_shared_user_idx", + "columns": [ + { + "expression": "shared_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_user_id_users_id_fk": { + "name": "payment_methods_user_id_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payment_methods_shared_user_id_users_id_fk": { + "name": "payment_methods_shared_user_id_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plans": { + "name": "plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "duration_days": { + "name": "duration_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recommendations": { + "name": "recommendations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actionable": { + "name": "actionable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "acted_at": { + "name": "acted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "rec_user_idx": { + "name": "rec_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rec_status_idx": { + "name": "rec_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rec_type_idx": { + "name": "rec_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rec_created_idx": { + "name": "rec_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recommendations_user_id_users_id_fk": { + "name": "recommendations_user_id_users_id_fk", + "tableFrom": "recommendations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "reports_user_id_users_id_fk": { + "name": "reports_user_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_transactions": { + "name": "scheduled_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "frequency": { + "name": "frequency", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "next_execution_date": { + "name": "next_execution_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "st_user_idx": { + "name": "st_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "st_next_date_idx": { + "name": "st_next_date_idx", + "columns": [ + { + "expression": "next_execution_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_transactions_user_id_users_id_fk": { + "name": "scheduled_transactions_user_id_users_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scheduled_transactions_category_id_categories_id_fk": { + "name": "scheduled_transactions_category_id_categories_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scheduled_transactions_payment_method_id_payment_methods_id_fk": { + "name": "scheduled_transactions_payment_method_id_payment_methods_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "payment_methods", + "columnsFrom": [ + "payment_method_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "frequency": { + "name": "frequency", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "retirement_date": { + "name": "retirement_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "sub_user_idx": { + "name": "sub_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sub_plan_idx": { + "name": "sub_plan_idx", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sub_active_idx": { + "name": "sub_active_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_users_id_fk": { + "name": "subscriptions_user_id_users_id_fk", + "tableFrom": "subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "subscriptions_plan_id_plans_id_fk": { + "name": "subscriptions_plan_id_plans_id_fk", + "tableFrom": "subscriptions", + "tableTo": "plans", + "columnsFrom": [ + "plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "scheduled_transaction_id": { + "name": "scheduled_transaction_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "debt_id": { + "name": "debt_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "contribution_id": { + "name": "contribution_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "budget_id": { + "name": "budget_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "tx_user_idx": { + "name": "tx_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_date_idx": { + "name": "tx_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_category_idx": { + "name": "tx_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_budget_idx": { + "name": "tx_budget_idx", + "columns": [ + { + "expression": "budget_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactions_user_id_users_id_fk": { + "name": "transactions_user_id_users_id_fk", + "tableFrom": "transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_category_id_categories_id_fk": { + "name": "transactions_category_id_categories_id_fk", + "tableFrom": "transactions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_payment_method_id_payment_methods_id_fk": { + "name": "transactions_payment_method_id_payment_methods_id_fk", + "tableFrom": "transactions", + "tableTo": "payment_methods", + "columnsFrom": [ + "payment_method_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_debt_id_debts_id_fk": { + "name": "transactions_debt_id_debts_id_fk", + "tableFrom": "transactions", + "tableTo": "debts", + "columnsFrom": [ + "debt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_contribution_id_goal_contributions_id_fk": { + "name": "transactions_contribution_id_goal_contributions_id_fk", + "tableFrom": "transactions", + "tableTo": "goal_contributions", + "columnsFrom": [ + "contribution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_budget_id_budgets_id_fk": { + "name": "transactions_budget_id_budgets_id_fk", + "tableFrom": "transactions", + "tableTo": "budgets", + "columnsFrom": [ + "budget_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "registration_date": { + "name": "registration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "recovery_token": { + "name": "recovery_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "recovery_token_expires": { + "name": "recovery_token_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "recommendations_enabled": { + "name": "recommendations_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index bf31a60..92411c4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,34 @@ "when": 1745570066019, "tag": "0007_acoustic_doctor_faustus", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1764112994585, + "tag": "0008_empty_guardsmen", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1764113188585, + "tag": "0009_silky_jamie_braddock", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1764113325919, + "tag": "0010_sharp_morgan_stark", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1764314743073, + "tag": "0011_true_leper_queen", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..999fe25 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6567 @@ +{ + "name": "finance-app", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "finance-app", + "version": "1.0.0", + "dependencies": { + "@getbrevo/brevo": "^2.2.0", + "@hono/zod-openapi": "^0.18.3", + "@langchain/core": "^0.3.77", + "@scalar/hono-api-reference": "^0.5.162", + "@types/json2csv": "^5.0.7", + "@types/uuid": "^10.0.0", + "cron": "^4.3.0", + "date-fns": "^4.1.0", + "dotenv": "^16.4.5", + "dotenv-expand": "^12.0.1", + "drizzle-orm": "^0.38.2", + "drizzle-zod": "^0.6.0", + "exceljs": "^4.4.0", + "hono": "^4.6.12", + "hono-pino": "^0.7.0", + "i": "^0.3.7", + "jose": "^5.9.6", + "json2csv": "^6.0.0-alpha.2", + "langchain": "^0.3.34", + "npm": "^11.3.0", + "pdf-lib": "^1.17.1", + "pdfkit": "^0.17.0", + "pg": "^8.13.1", + "pino": "^9.5.0", + "pino-pretty": "^13.0.0", + "stoker": "^1.4.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/pdfkit": "^0.13.9", + "@types/pg": "^8.11.10", + "drizzle-kit": "^0.30.1", + "vitest": "^4.0.14" + } + }, + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "openapi3-ts": "^4.1.2" + }, + "peerDependencies": { + "zod": "^3.20.2" + } + }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "license": "MIT" + }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.6.1", + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "license": "MIT" + }, + "node_modules/@getbrevo/brevo": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.0", + "request": "^2.81.0", + "rewire": "^7.0.0" + } + }, + "node_modules/@hono/zod-openapi": { + "version": "0.18.3", + "license": "MIT", + "dependencies": { + "@asteasolutions/zod-to-openapi": "^7.1.0", + "@hono/zod-validator": "^0.4.1" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "hono": ">=4.3.6", + "zod": "3.*" + } + }, + "node_modules/@hono/zod-validator": { + "version": "0.4.1", + "license": "MIT", + "peerDependencies": { + "hono": ">=3.9.0", + "zod": "^3.19.1" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "license": "BSD-3-Clause" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@langchain/core": { + "version": "0.3.77", + "license": "MIT", + "dependencies": { + "@cfworker/json-schema": "^4.0.2", + "ansi-styles": "^5.0.0", + "camelcase": "6", + "decamelize": "1.2.0", + "js-tiktoken": "^1.0.12", + "langsmith": "^0.3.67", + "mustache": "^4.2.0", + "p-queue": "^6.6.2", + "p-retry": "4", + "uuid": "^10.0.0", + "zod": "^3.25.32", + "zod-to-json-schema": "^3.22.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@langchain/openai": { + "version": "0.6.13", + "license": "MIT", + "dependencies": { + "js-tiktoken": "^1.0.12", + "openai": "5.12.2", + "zod": "^3.25.32" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.3.68 <0.4.0" + } + }, + "node_modules/@langchain/textsplitters": { + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "js-tiktoken": "^1.0.12" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": ">=0.2.21 <0.4.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@scalar/hono-api-reference": { + "version": "0.5.162", + "license": "MIT", + "dependencies": { + "@scalar/types": "0.0.22" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "hono": "^4.0.0" + } + }, + "node_modules/@scalar/openapi-types": { + "version": "0.1.5", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@scalar/types": { + "version": "0.0.22", + "license": "MIT", + "dependencies": { + "@scalar/openapi-types": "0.1.5", + "@unhead/schema": "^1.11.11" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@streamparser/json": { + "version": "0.0.6", + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.17", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@swc/helpers/node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/@types/bun": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.3.tgz", + "integrity": "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.3" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json2csv": { + "version": "5.0.7", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/luxon": { + "version": "3.6.2", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/pdfkit": { + "version": "0.13.9", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/pg": { + "version": "8.11.10", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^4.0.1" + } + }, + "node_modules/@types/react": { + "version": "19.2.2", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "license": "ISC" + }, + "node_modules/@unhead/schema": { + "version": "1.11.13", + "license": "MIT", + "dependencies": { + "hookable": "^5.5.3", + "zhead": "^2.2.4" + }, + "funding": { + "url": "https://github.com/sponsors/harlan-zw" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.14", + "@vitest/utils": "4.0.14", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.14", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.14", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.14", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.14", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.14.1", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/archiver": { + "version": "5.3.2", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/readable-stream/node_modules/string_decoder": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "license": "Python-2.0" + }, + "node_modules/asn1": { + "version": "0.2.6", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "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/bcrypt-pbkdf": { + "version": "1.0.2", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/brotli": { + "version": "1.3.3", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "engines": { + "node": ">=0.2.0" + } + }, + "node_modules/bun-types": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.3.tgz", + "integrity": "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "license": "Apache-2.0" + }, + "node_modules/chai": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chainsaw": { + "version": "0.1.0", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "6.2.1", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "license": "MIT" + }, + "node_modules/console-table-printer": { + "version": "2.14.6", + "license": "MIT", + "dependencies": { + "simple-wcswidth": "^1.0.1" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cron": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.6.0", + "luxon": "~3.6.0" + }, + "engines": { + "node": ">=18.x" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.1.3", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/dashdash": { + "version": "1.14.1", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/dateformat": { + "version": "4.6.3", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.0", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.4", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dfa": { + "version": "1.2.0", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.1", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/drizzle-kit": { + "version": "0.30.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.19.7", + "esbuild-register": "^3.5.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.38.2", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/react": ">=18", + "@types/sql.js": "*", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "react": ">=18", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/drizzle-zod": { + "version": "0.6.0", + "license": "Apache-2.0", + "peerDependencies": { + "drizzle-orm": ">=0.36.0", + "zod": ">=3.0.0" + } + }, + "node_modules/duplexer2": { + "version": "0.1.4", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/readable-stream/node_modules/string_decoder": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.19.12", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, + "node_modules/esbuild-register": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "esbuild": ">=0.12 <1" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "license": "MIT" + }, + "node_modules/exceljs": { + "version": "4.4.0", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/uuid": { + "version": "8.3.2", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/expect-type": { + "version": "1.2.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "license": "MIT" + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/fast-copy": { + "version": "3.0.2", + "license": "MIT" + }, + "node_modules/fast-csv": { + "version": "4.3.6", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "license": "MIT" + }, + "node_modules/fast-redact": { + "version": "3.5.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flat-cache/node_modules/rimraf": { + "version": "3.0.2", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "license": "ISC" + }, + "node_modules/fontkit": { + "version": "2.0.4", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "2.3.3", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/fstream": { + "version": "1.0.12", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/fstream/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/fstream/node_modules/rimraf": { + "version": "2.7.1", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/get-tsconfig": { + "version": "4.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "license": "MIT" + }, + "node_modules/har-schema": { + "version": "2.0.0", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "license": "MIT" + }, + "node_modules/hono": { + "version": "4.6.12", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/hono-pino": { + "version": "0.7.0", + "license": "MIT", + "dependencies": { + "defu": "^6.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "hono": ">=4.0.0", + "pino": ">=7.1.0" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "license": "MIT" + }, + "node_modules/http-signature": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, + "node_modules/i": { + "version": "0.3.7", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "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": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/isstream": { + "version": "0.1.2", + "license": "MIT" + }, + "node_modules/jose": { + "version": "5.9.6", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/jpeg-exif": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsbn": { + "version": "0.1.1", + "license": "MIT" + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "license": "MIT" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "license": "ISC" + }, + "node_modules/json2csv": { + "version": "6.0.0-alpha.2", + "license": "MIT", + "dependencies": { + "@streamparser/json": "^0.0.6", + "commander": "^6.2.0", + "lodash.get": "^4.4.2" + }, + "bin": { + "json2csv": "bin/json2csv.js" + }, + "engines": { + "node": ">= 12", + "npm": ">= 6.13.0" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jsprim": { + "version": "1.4.2", + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream/node_modules/string_decoder": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/langchain": { + "version": "0.3.34", + "license": "MIT", + "dependencies": { + "@langchain/openai": ">=0.1.0 <0.7.0", + "@langchain/textsplitters": ">=0.0.0 <0.2.0", + "js-tiktoken": "^1.0.12", + "js-yaml": "^4.1.0", + "jsonpointer": "^5.0.1", + "langsmith": "^0.3.67", + "openapi-types": "^12.1.3", + "p-retry": "4", + "uuid": "^10.0.0", + "yaml": "^2.2.1", + "zod": "^3.25.32" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/anthropic": "*", + "@langchain/aws": "*", + "@langchain/cerebras": "*", + "@langchain/cohere": "*", + "@langchain/core": ">=0.3.58 <0.4.0", + "@langchain/deepseek": "*", + "@langchain/google-genai": "*", + "@langchain/google-vertexai": "*", + "@langchain/google-vertexai-web": "*", + "@langchain/groq": "*", + "@langchain/mistralai": "*", + "@langchain/ollama": "*", + "@langchain/xai": "*", + "axios": "*", + "cheerio": "*", + "handlebars": "^4.7.8", + "peggy": "^3.0.2", + "typeorm": "*" + }, + "peerDependenciesMeta": { + "@langchain/anthropic": { + "optional": true + }, + "@langchain/aws": { + "optional": true + }, + "@langchain/cerebras": { + "optional": true + }, + "@langchain/cohere": { + "optional": true + }, + "@langchain/deepseek": { + "optional": true + }, + "@langchain/google-genai": { + "optional": true + }, + "@langchain/google-vertexai": { + "optional": true + }, + "@langchain/google-vertexai-web": { + "optional": true + }, + "@langchain/groq": { + "optional": true + }, + "@langchain/mistralai": { + "optional": true + }, + "@langchain/ollama": { + "optional": true + }, + "@langchain/xai": { + "optional": true + }, + "axios": { + "optional": true + }, + "cheerio": { + "optional": true + }, + "handlebars": { + "optional": true + }, + "peggy": { + "optional": true + }, + "typeorm": { + "optional": true + } + } + }, + "node_modules/langsmith": { + "version": "0.3.71", + "license": "MIT", + "dependencies": { + "@types/uuid": "^10.0.0", + "chalk": "^4.1.2", + "console-table-printer": "^2.12.1", + "p-queue": "^6.6.2", + "p-retry": "4", + "semver": "^7.6.3", + "uuid": "^10.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": "*", + "@opentelemetry/exporter-trace-otlp-proto": "*", + "@opentelemetry/sdk-trace-base": "*", + "openai": "*" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "openai": { + "optional": true + } + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream/node_modules/string_decoder": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/linebreak": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "license": "ISC" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "license": "MIT" + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.6.1", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/mustache": { + "version": "4.2.0", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "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/natural-compare": { + "version": "1.4.0", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm": { + "version": "11.3.0", + "bundleDependencies": [ + "@isaacs/string-locale-compare", + "@npmcli/arborist", + "@npmcli/config", + "@npmcli/fs", + "@npmcli/map-workspaces", + "@npmcli/package-json", + "@npmcli/promise-spawn", + "@npmcli/redact", + "@npmcli/run-script", + "@sigstore/tuf", + "abbrev", + "archy", + "cacache", + "chalk", + "ci-info", + "cli-columns", + "fastest-levenshtein", + "fs-minipass", + "glob", + "graceful-fs", + "hosted-git-info", + "ini", + "init-package-json", + "is-cidr", + "json-parse-even-better-errors", + "libnpmaccess", + "libnpmdiff", + "libnpmexec", + "libnpmfund", + "libnpmorg", + "libnpmpack", + "libnpmpublish", + "libnpmsearch", + "libnpmteam", + "libnpmversion", + "make-fetch-happen", + "minimatch", + "minipass", + "minipass-pipeline", + "ms", + "node-gyp", + "nopt", + "normalize-package-data", + "npm-audit-report", + "npm-install-checks", + "npm-package-arg", + "npm-pick-manifest", + "npm-profile", + "npm-registry-fetch", + "npm-user-validate", + "p-map", + "pacote", + "parse-conflict-json", + "proc-log", + "qrcode-terminal", + "read", + "semver", + "spdx-expression-parse", + "ssri", + "supports-color", + "tar", + "text-table", + "tiny-relative-date", + "treeverse", + "validate-npm-package-name", + "which" + ], + "license": "Artistic-2.0", + "workspaces": [ + "docs", + "smoke-tests", + "mock-globals", + "mock-registry", + "workspaces/*" + ], + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/arborist": "^9.0.2", + "@npmcli/config": "^10.2.0", + "@npmcli/fs": "^4.0.0", + "@npmcli/map-workspaces": "^4.0.2", + "@npmcli/package-json": "^6.1.1", + "@npmcli/promise-spawn": "^8.0.2", + "@npmcli/redact": "^3.1.1", + "@npmcli/run-script": "^9.1.0", + "@sigstore/tuf": "^3.0.0", + "abbrev": "^3.0.0", + "archy": "~1.0.0", + "cacache": "^19.0.1", + "chalk": "^5.4.1", + "ci-info": "^4.2.0", + "cli-columns": "^4.0.0", + "fastest-levenshtein": "^1.0.16", + "fs-minipass": "^3.0.3", + "glob": "^10.4.5", + "graceful-fs": "^4.2.11", + "hosted-git-info": "^8.0.2", + "ini": "^5.0.0", + "init-package-json": "^8.0.0", + "is-cidr": "^5.1.1", + "json-parse-even-better-errors": "^4.0.0", + "libnpmaccess": "^10.0.0", + "libnpmdiff": "^8.0.2", + "libnpmexec": "^10.1.1", + "libnpmfund": "^7.0.2", + "libnpmorg": "^8.0.0", + "libnpmpack": "^9.0.2", + "libnpmpublish": "^11.0.0", + "libnpmsearch": "^9.0.0", + "libnpmteam": "^8.0.0", + "libnpmversion": "^8.0.0", + "make-fetch-happen": "^14.0.3", + "minimatch": "^9.0.5", + "minipass": "^7.1.1", + "minipass-pipeline": "^1.2.4", + "ms": "^2.1.2", + "node-gyp": "^11.2.0", + "nopt": "^8.1.0", + "normalize-package-data": "^7.0.0", + "npm-audit-report": "^6.0.0", + "npm-install-checks": "^7.1.1", + "npm-package-arg": "^12.0.2", + "npm-pick-manifest": "^10.0.0", + "npm-profile": "^11.0.1", + "npm-registry-fetch": "^18.0.2", + "npm-user-validate": "^3.0.0", + "p-map": "^7.0.3", + "pacote": "^21.0.0", + "parse-conflict-json": "^4.0.0", + "proc-log": "^5.0.0", + "qrcode-terminal": "^0.12.0", + "read": "^4.1.0", + "semver": "^7.7.1", + "spdx-expression-parse": "^4.0.0", + "ssri": "^12.0.0", + "supports-color": "^10.0.0", + "tar": "^6.2.1", + "text-table": "~0.2.0", + "tiny-relative-date": "^1.3.0", + "treeverse": "^3.0.0", + "validate-npm-package-name": "^6.0.0", + "which": "^5.0.0" + }, + "bin": { + "npm": "bin/npm-cli.js", + "npx": "bin/npx-cli.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui": { + "version": "8.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/npm/node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/@npmcli/agent": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/arborist": { + "version": "9.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^4.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/map-workspaces": "^4.0.1", + "@npmcli/metavuln-calculator": "^9.0.0", + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.1", + "@npmcli/query": "^4.0.0", + "@npmcli/redact": "^3.0.0", + "@npmcli/run-script": "^9.0.1", + "bin-links": "^5.0.0", + "cacache": "^19.0.1", + "common-ancestor-path": "^1.0.1", + "hosted-git-info": "^8.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^10.2.2", + "minimatch": "^9.0.4", + "nopt": "^8.0.0", + "npm-install-checks": "^7.1.0", + "npm-package-arg": "^12.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.1", + "pacote": "^21.0.0", + "parse-conflict-json": "^4.0.0", + "proc-log": "^5.0.0", + "proggy": "^3.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "read-package-json-fast": "^4.0.0", + "semver": "^7.3.7", + "ssri": "^12.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^4.0.0" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/config": { + "version": "10.2.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^4.0.1", + "@npmcli/package-json": "^6.0.1", + "ci-info": "^4.0.0", + "ini": "^5.0.0", + "nopt": "^8.1.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/fs": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/git": { + "version": "6.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/promise-spawn": "^8.0.0", + "ini": "^5.0.0", + "lru-cache": "^10.0.1", + "npm-pick-manifest": "^10.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "semver": "^7.3.5", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/installed-package-contents": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/map-workspaces": { + "version": "4.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "glob": "^10.2.2", + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { + "version": "9.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "cacache": "^19.0.0", + "json-parse-even-better-errors": "^4.0.0", + "pacote": "^21.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/@npmcli/name-from-folder": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/node-gyp": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/package-json": { + "version": "6.1.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "glob": "^10.2.2", + "hosted-git-info": "^8.0.0", + "json-parse-even-better-errors": "^4.0.0", + "proc-log": "^5.0.0", + "semver": "^7.5.3", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/promise-spawn": { + "version": "8.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/query": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/redact": { + "version": "3.1.1", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@npmcli/run-script": { + "version": "9.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^4.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "node-gyp": "^11.0.0", + "proc-log": "^5.0.0", + "which": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/@sigstore/bundle": { + "version": "3.1.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.4.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/core": { + "version": "2.0.0", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/protobuf-specs": { + "version": "0.4.0", + "inBundle": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/sign": { + "version": "3.1.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "make-fetch-happen": "^14.0.2", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/tuf": { + "version": "3.1.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.4.0", + "tuf-js": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@sigstore/verify": { + "version": "2.1.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/@tufjs/models": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^9.0.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/abbrev": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/agent-base": { + "version": "7.1.3", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/ansi-regex": { + "version": "5.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/ansi-styles": { + "version": "6.2.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm/node_modules/aproba": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/archy": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/balanced-match": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/bin-links": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^7.0.0", + "npm-normalize-package-bin": "^4.0.0", + "proc-log": "^5.0.0", + "read-cmd-shim": "^5.0.0", + "write-file-atomic": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/binary-extensions": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/brace-expansion": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/npm/node_modules/cacache": { + "version": "19.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/chownr": { + "version": "3.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/minizlib": { + "version": "3.0.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/mkdirp": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/tar": { + "version": "7.4.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/cacache/node_modules/yallist": { + "version": "5.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/chalk": { + "version": "5.4.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/npm/node_modules/chownr": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/ci-info": { + "version": "4.2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/cidr-regex": { + "version": "4.1.3", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "ip-regex": "^5.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/cli-columns": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/npm/node_modules/cmd-shim": { + "version": "7.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/color-convert": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/npm/node_modules/color-name": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/common-ancestor-path": { + "version": "1.0.1", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/cross-spawn": { + "version": "7.0.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/cssesc": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/debug": { + "version": "4.4.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/diff": { + "version": "7.0.0", + "inBundle": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/npm/node_modules/eastasianwidth": { + "version": "0.2.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/emoji-regex": { + "version": "8.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/encoding": { + "version": "0.1.13", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/npm/node_modules/env-paths": { + "version": "2.2.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/npm/node_modules/err-code": { + "version": "2.0.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/exponential-backoff": { + "version": "3.1.2", + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/npm/node_modules/fastest-levenshtein": { + "version": "1.0.16", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/npm/node_modules/foreground-child": { + "version": "3.3.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/fs-minipass": { + "version": "3.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/glob": { + "version": "10.4.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/graceful-fs": { + "version": "4.2.11", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/hosted-git-info": { + "version": "8.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/http-cache-semantics": { + "version": "4.1.1", + "inBundle": true, + "license": "BSD-2-Clause" + }, + "node_modules/npm/node_modules/http-proxy-agent": { + "version": "7.0.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/https-proxy-agent": { + "version": "7.0.6", + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/iconv-lite": { + "version": "0.6.3", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm/node_modules/ignore-walk": { + "version": "7.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minimatch": "^9.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/imurmurhash": { + "version": "0.1.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/npm/node_modules/ini": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/init-package-json": { + "version": "8.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/package-json": "^6.1.0", + "npm-package-arg": "^12.0.0", + "promzard": "^2.0.0", + "read": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/ip-address": { + "version": "9.0.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/npm/node_modules/ip-regex": { + "version": "5.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/is-cidr": { + "version": "5.1.1", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "cidr-regex": "^4.1.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/npm/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/isexe": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/jackspeak": { + "version": "3.4.3", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/npm/node_modules/jsbn": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/json-parse-even-better-errors": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/json-stringify-nice": { + "version": "1.1.4", + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/jsonparse": { + "version": "1.3.1", + "engines": [ + "node >= 0.2.0" + ], + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff": { + "version": "6.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/just-diff-apply": { + "version": "5.5.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/libnpmaccess": { + "version": "10.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-package-arg": "^12.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmdiff": { + "version": "8.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.0.2", + "@npmcli/installed-package-contents": "^3.0.0", + "binary-extensions": "^3.0.0", + "diff": "^7.0.0", + "minimatch": "^9.0.4", + "npm-package-arg": "^12.0.0", + "pacote": "^21.0.0", + "tar": "^6.2.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmexec": { + "version": "10.1.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.0.2", + "@npmcli/package-json": "^6.1.1", + "@npmcli/run-script": "^9.0.1", + "ci-info": "^4.0.0", + "npm-package-arg": "^12.0.0", + "pacote": "^21.0.0", + "proc-log": "^5.0.0", + "read": "^4.0.0", + "read-package-json-fast": "^4.0.0", + "semver": "^7.3.7", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmfund": { + "version": "7.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.0.2" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmorg": { + "version": "8.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmpack": { + "version": "9.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/arborist": "^9.0.2", + "@npmcli/run-script": "^9.0.1", + "npm-package-arg": "^12.0.0", + "pacote": "^21.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmpublish": { + "version": "11.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "ci-info": "^4.0.0", + "normalize-package-data": "^7.0.0", + "npm-package-arg": "^12.0.0", + "npm-registry-fetch": "^18.0.1", + "proc-log": "^5.0.0", + "semver": "^7.3.7", + "sigstore": "^3.0.0", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmsearch": { + "version": "9.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmteam": { + "version": "8.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "aproba": "^2.0.0", + "npm-registry-fetch": "^18.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/libnpmversion": { + "version": "8.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.1", + "@npmcli/run-script": "^9.0.1", + "json-parse-even-better-errors": "^4.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/lru-cache": { + "version": "10.4.3", + "inBundle": true, + "license": "ISC" + }, + "node_modules/npm/node_modules/make-fetch-happen": { + "version": "14.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/make-fetch-happen/node_modules/negotiator": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/npm/node_modules/minimatch": { + "version": "9.0.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/minipass": { + "version": "7.1.2", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-collect": { + "version": "2.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/npm/node_modules/minipass-fetch": { + "version": "4.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/npm/node_modules/minipass-fetch/node_modules/minizlib": { + "version": "3.0.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/minipass-flush": { + "version": "1.0.5", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline": { + "version": "1.2.4", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-sized": { + "version": "1.0.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/minizlib": { + "version": "2.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/mkdirp": { + "version": "1.0.4", + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/ms": { + "version": "2.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/mute-stream": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/node-gyp": { + "version": "11.2.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "tinyglobby": "^0.2.12", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/chownr": { + "version": "3.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/minizlib": { + "version": "3.0.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/mkdirp": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/tar": { + "version": "7.4.3", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/node-gyp/node_modules/yallist": { + "version": "5.0.0", + "inBundle": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/npm/node_modules/nopt": { + "version": "8.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/normalize-package-data": { + "version": "7.0.0", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^8.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-audit-report": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-bundled": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-install-checks": { + "version": "7.1.1", + "inBundle": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-package-arg": { + "version": "12.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-packlist": { + "version": "10.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/npm-pick-manifest": { + "version": "10.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^7.1.0", + "npm-normalize-package-bin": "^4.0.0", + "npm-package-arg": "^12.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-profile": { + "version": "11.0.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch": { + "version": "18.0.2", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^3.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^14.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^12.0.0", + "proc-log": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/npm-registry-fetch/node_modules/minizlib": { + "version": "3.0.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/npm/node_modules/npm-user-validate": { + "version": "3.0.0", + "inBundle": true, + "license": "BSD-2-Clause", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/p-map": { + "version": "7.0.3", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/package-json-from-dist": { + "version": "1.0.1", + "inBundle": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/npm/node_modules/pacote": { + "version": "21.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^6.0.0", + "@npmcli/installed-package-contents": "^3.0.0", + "@npmcli/package-json": "^6.0.0", + "@npmcli/promise-spawn": "^8.0.0", + "@npmcli/run-script": "^9.0.0", + "cacache": "^19.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^12.0.0", + "npm-packlist": "^10.0.0", + "npm-pick-manifest": "^10.0.0", + "npm-registry-fetch": "^18.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "sigstore": "^3.0.0", + "ssri": "^12.0.0", + "tar": "^6.1.11" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm/node_modules/parse-conflict-json": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/path-key": { + "version": "3.1.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/path-scurry": { + "version": "1.11.1", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm/node_modules/proc-log": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/proggy": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/promise-all-reject-late": { + "version": "1.0.1", + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-call-limit": { + "version": "3.0.2", + "inBundle": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/promise-retry": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/promzard": { + "version": "2.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "read": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/qrcode-terminal": { + "version": "0.12.0", + "inBundle": true, + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/npm/node_modules/read": { + "version": "4.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "mute-stream": "^2.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/read-cmd-shim": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/read-package-json-fast": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^4.0.0", + "npm-normalize-package-bin": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/retry": { + "version": "0.12.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/npm/node_modules/safer-buffer": { + "version": "2.1.2", + "inBundle": true, + "license": "MIT", + "optional": true + }, + "node_modules/npm/node_modules/semver": { + "version": "7.7.1", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/shebang-command": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/shebang-regex": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/signal-exit": { + "version": "4.1.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/npm/node_modules/sigstore": { + "version": "3.1.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^3.1.0", + "@sigstore/core": "^2.0.0", + "@sigstore/protobuf-specs": "^0.4.0", + "@sigstore/sign": "^3.1.0", + "@sigstore/tuf": "^3.1.0", + "@sigstore/verify": "^2.1.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/smart-buffer": { + "version": "4.2.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks": { + "version": "2.8.4", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/npm/node_modules/socks-proxy-agent": { + "version": "8.0.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/npm/node_modules/spdx-correct": { + "version": "3.2.0", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-correct/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-exceptions": { + "version": "2.5.0", + "inBundle": true, + "license": "CC-BY-3.0" + }, + "node_modules/npm/node_modules/spdx-expression-parse": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/spdx-license-ids": { + "version": "3.0.21", + "inBundle": true, + "license": "CC0-1.0" + }, + "node_modules/npm/node_modules/sprintf-js": { + "version": "1.1.3", + "inBundle": true, + "license": "BSD-3-Clause" + }, + "node_modules/npm/node_modules/ssri": { + "version": "12.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/string-width": { + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/strip-ansi": { + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/supports-color": { + "version": "10.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/npm/node_modules/tar": { + "version": "6.2.1", + "inBundle": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm/node_modules/tar/node_modules/fs-minipass": { + "version": "2.1.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/npm/node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "inBundle": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/npm/node_modules/text-table": { + "version": "0.2.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tiny-relative-date": { + "version": "1.3.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/tinyglobby": { + "version": "0.2.12", + "inBundle": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.3", + "inBundle": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/npm/node_modules/treeverse": { + "version": "3.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/npm/node_modules/tuf-js": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "3.0.1", + "debug": "^4.3.6", + "make-fetch-happen": "^14.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/unique-filename": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/unique-slug": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/util-deprecate": { + "version": "1.0.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/validate-npm-package-license": { + "version": "3.0.4", + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/npm/node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/npm/node_modules/validate-npm-package-name": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/walk-up-path": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/npm/node_modules/which": { + "version": "5.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/which/node_modules/isexe": { + "version": "3.1.1", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/npm/node_modules/wrap-ansi": { + "version": "8.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "9.2.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "inBundle": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm/node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/npm/node_modules/write-file-atomic": { + "version": "6.0.0", + "inBundle": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/npm/node_modules/yallist": { + "version": "4.0.0", + "inBundle": true, + "license": "ISC" + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "devOptional": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.1", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "5.12.2", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "license": "MIT" + }, + "node_modules/openapi3-ts": { + "version": "4.4.0", + "license": "MIT", + "dependencies": { + "yaml": "^2.5.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "6.6.2", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdfkit": { + "version": "0.17.0", + "license": "MIT", + "dependencies": { + "crypto-js": "^4.2.0", + "fontkit": "^2.0.4", + "jpeg-exif": "^1.1.4", + "linebreak": "^1.1.0", + "png-js": "^1.0.0" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.13.1", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.7.0", + "pg-pool": "^3.7.0", + "pg-protocol": "^1.7.0", + "pg-types": "^2.1.0", + "pgpass": "1.x" + }, + "engines": { + "node": ">= 8.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.1.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.1.1", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.7.0", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-numeric": { + "version": "1.0.2", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-pool": { + "version": "3.7.0", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.7.0", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "4.0.2", + "devOptional": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pg/node_modules/pg-types": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg/node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg/node_modules/pg-types/node_modules/postgres-bytea": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pg/node_modules/pg-types/node_modules/postgres-date": { + "version": "1.0.7", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pg/node_modules/pg-types/node_modules/postgres-interval": { + "version": "1.2.0", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.5.0", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^4.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.0.0", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.2", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^2.4.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "license": "MIT" + }, + "node_modules/png-js": { + "version": "1.0.0" + }, + "node_modules/postcss": { + "version": "8.5.6", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "3.0.2", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "3.0.0", + "devOptional": true, + "license": "MIT", + "dependencies": { + "obuf": "~1.1.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/postgres-date": { + "version": "2.1.0", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-interval": { + "version": "3.0.0", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-range": { + "version": "1.1.4", + "devOptional": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/pump": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.5.3", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "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/quick-format-unescaped": { + "version": "4.0.4", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/readdir-glob/node_modules/minimatch/node_modules/brace-expansion": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/request": { + "version": "2.88.2", + "license": "Apache-2.0", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request/node_modules/uuid": { + "version": "3.4.0", + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restructure": { + "version": "3.0.2", + "license": "MIT" + }, + "node_modules/retry": { + "version": "0.13.1", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rewire": { + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "eslint": "^8.47.0" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "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", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "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/safe-stable-stringify": { + "version": "2.5.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "5.0.1", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-wcswidth": { + "version": "1.1.2", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sshpk": { + "version": "1.18.0", + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "dev": true, + "license": "MIT" + }, + "node_modules/stoker": { + "version": "1.4.2", + "license": "MIT", + "peerDependencies": { + "@asteasolutions/zod-to-openapi": "^7.0.0", + "@hono/zod-openapi": ">=0.16.0", + "hono": "^4.0.0", + "openapi3-ts": "^4.4.0" + }, + "peerDependenciesMeta": { + "@hono/zod-openapi": { + "optional": true + } + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "license": "MIT" + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.3", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/traverse": { + "version": "0.3.9", + "license": "MIT/X11" + }, + "node_modules/tslib": { + "version": "1.14.1", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "license": "Unlicense" + }, + "node_modules/type-check": { + "version": "0.4.0", + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "license": "MIT" + }, + "node_modules/unzipper": { + "version": "0.10.14", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/bluebird": { + "version": "3.4.7", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/readable-stream/node_modules/string_decoder": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "10.0.0", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/vite": { + "version": "7.2.4", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.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_modules/esbuild": { + "version": "0.25.12", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vite/node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest": { + "version": "4.0.14", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.14", + "@vitest/mocker": "4.0.14", + "@vitest/pretty-format": "4.0.14", + "@vitest/runner": "4.0.14", + "@vitest/snapshot": "4.0.14", + "@vitest/spy": "4.0.14", + "@vitest/utils": "4.0.14", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.14", + "@vitest/browser-preview": "4.0.14", + "@vitest/browser-webdriverio": "4.0.14", + "@vitest/ui": "4.0.14", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "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/word-wrap": { + "version": "1.2.5", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "license": "ISC" + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yaml": { + "version": "2.6.1", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zhead": { + "version": "2.2.4", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/harlan-zw" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "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" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.6", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + } + } +} diff --git a/package.json b/package.json index 643d89f..ecdf1b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "finance-app", "version": "1.0.0", + "type": "module", "scripts": { "dev": "bun run --hot src/index.ts", "build": "bun build src/index.ts --outdir dist --target node --external bun", @@ -8,7 +9,8 @@ "generate": "bun drizzle-kit generate", "db:seed": "bun run src/core/infrastructure/scripts/categories.seed.ts", "db:clean": "bun run src/core/infrastructure/scripts/clean-database.ts", - "db:refresh": "bun run db:clean && bun run db:seed" + "db:refresh": "bun run db:clean && bun run db:seed", + "test": "vitest" }, "dependencies": { "@getbrevo/brevo": "^2.2.0", @@ -31,6 +33,7 @@ "json2csv": "^6.0.0-alpha.2", "langchain": "^0.3.34", "npm": "^11.3.0", + "openai": "^6.9.1", "pdf-lib": "^1.17.1", "pdfkit": "^0.17.0", "pg": "^8.13.1", @@ -43,6 +46,8 @@ "@types/bun": "latest", "@types/pdfkit": "^0.13.9", "@types/pg": "^8.11.10", - "drizzle-kit": "^0.30.1" + "drizzle-kit": "^0.30.1", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.0.14" } -} +} \ No newline at end of file diff --git a/restore-backup.sh b/restore-backup.sh new file mode 100755 index 0000000..0ee4af5 --- /dev/null +++ b/restore-backup.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +# Script para restaurar el backup de la base de datos +# Uso: ./restore-backup.sh [nombre_del_backup.sql] + +BACKUP_FILE=${1:-$(ls -t backup_*.sql | head -1)} + +if [ ! -f "$BACKUP_FILE" ]; then + echo "❌ Error: No se encontró el archivo de backup: $BACKUP_FILE" + echo "Archivos de backup disponibles:" + ls -lh backup_*.sql 2>/dev/null || echo "No hay archivos de backup" + exit 1 +fi + +echo "📦 Restaurando backup: $BACKUP_FILE" +echo "⚠️ Esto borrará todos los datos actuales en la base de datos 'database'" +read -p "¿Continuar? (s/N): " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Ss]$ ]]; then + echo "Operación cancelada" + exit 1 +fi + +# Asegurar que el contenedor esté corriendo +echo "🔧 Verificando contenedor de base de datos..." +docker compose up -d db + +# Esperar a que PostgreSQL esté listo +echo "⏳ Esperando a que PostgreSQL esté listo..." +sleep 5 + +# Restaurar el backup +echo "📥 Restaurando datos..." +docker exec -i container psql -U username -d database < "$BACKUP_FILE" + +if [ $? -eq 0 ]; then + echo "✅ Backup restaurado exitosamente!" + echo "📊 Verificando datos..." + docker exec container psql -U username -d database -c "SELECT COUNT(*) as total_tablas FROM information_schema.tables WHERE table_schema = 'public';" +else + echo "❌ Error al restaurar el backup" + exit 1 +fi + diff --git a/src/app.ts b/src/app.ts index 25f8c90..26430fd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -24,6 +24,7 @@ import { startBudgetSummaryJob } from "./core/infrastructure/cron/budget-notific import { startGoalNotificationsJob } from "./core/infrastructure/cron/goal-notifications.cron"; import { startFinancialSuggestionsJob } from "./core/infrastructure/cron/financial-suggestions.cron"; import { startGoalSuggestionsJob } from "./core/infrastructure/cron/goal-suggestions.cron"; +import { startDailyRecommendationsJob } from "./core/infrastructure/cron/daily-recommendations.cron"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; import { createMiddleware } from "hono/factory"; @@ -39,6 +40,8 @@ import { ExcelService } from "./features/reports/infrastructure/services/excel.s import { CSVService } from "./features/reports/infrastructure/services/csv.service"; import reports from "./features/reports/infrastructure/controllers/report.controller"; import aiAgents from "./features/ai-agents/infrastructure/controllers/voice-command.controller"; +import subscriptions from "./features/subscriptions/infrastructure/controllers/subscription.controller"; +import recommendations from "./features/recommendations/infrastructure/controllers/recommendation.controller"; const app = createApp(); @@ -51,16 +54,23 @@ startBudgetSummaryJob(); startGoalNotificationsJob(); startFinancialSuggestionsJob(); startGoalSuggestionsJob(); +startDailyRecommendationsJob(); configureOpenAPI(app); // Configuración CORS mejorada app.use( cors({ - origin: ['http://localhost:3001', 'http://localhost:3000', '*'], - allowHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept', 'Authorization'], - allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'], + origin: ["http://localhost:3001", "http://localhost:3000", "*"], + allowHeaders: [ + "Origin", + "X-Requested-With", + "Content-Type", + "Accept", + "Authorization", + ], + allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], credentials: true, - exposeHeaders: ['Content-Length', 'X-Kuma-Revision'] + exposeHeaders: ["Content-Length", "X-Kuma-Revision"], }) ); @@ -111,7 +121,9 @@ const routes = [ email, reports, aiAgents, + recommendations, notificationSocket, + subscriptions, ] as const; app.get("/debug/db-status", (c) => { @@ -128,4 +140,4 @@ routes.forEach((route) => { export type AppType = (typeof routes)[number]; -export default app; \ No newline at end of file +export default app; diff --git a/src/core/infrastructure/cron/daily-recommendations.cron.ts b/src/core/infrastructure/cron/daily-recommendations.cron.ts new file mode 100644 index 0000000..4011ae9 --- /dev/null +++ b/src/core/infrastructure/cron/daily-recommendations.cron.ts @@ -0,0 +1,76 @@ +import cron from "cron"; +import { db } from "@/db"; +import { users } from "@/schema"; +import { eq } from "drizzle-orm"; +import { RecommendationOrchestratorService } from "@/features/recommendations/application/services/recommendation-orchestrator.service"; +import { PgRecommendationRepository } from "@/features/recommendations/infrastructure/adapters/pg-recommendation.repository"; + +const recommendationRepository = PgRecommendationRepository.getInstance(); +const orchestrator = RecommendationOrchestratorService.getInstance( + recommendationRepository +); + +const dailyRecommendationsJob = new cron.CronJob( + "0 6 * * *", + async () => { + console.log("Running daily recommendations job..."); + + try { + const enabledUsers = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.recommendations_enabled, true)); + + console.log( + `Found ${enabledUsers.length} users with recommendations enabled` + ); + + let successCount = 0; + let errorCount = 0; + + for (const user of enabledUsers) { + try { + const recommendation = await orchestrator.generateDailyRecommendation( + user.id + ); + + if (recommendation) { + console.log( + `Generated recommendation for user ${user.id}: ${recommendation.type}` + ); + successCount++; + } else { + console.log( + `No recommendation generated for user ${user.id} (already has one or no insights found)` + ); + } + } catch (error) { + console.error( + `Failed to generate recommendation for user ${user.id}:`, + error + ); + errorCount++; + } + } + + console.log( + `Daily recommendations job completed: ${successCount} successful, ${errorCount} errors` + ); + } catch (error) { + console.error("Error in daily recommendations job:", error); + } + }, + null, + false, + "America/New_York" +); + +export function startDailyRecommendationsJob() { + dailyRecommendationsJob.start(); + console.log("Daily recommendations job started (runs at 6 AM daily)"); +} + +export function stopDailyRecommendationsJob() { + dailyRecommendationsJob.stop(); + console.log("Daily recommendations job stopped"); +} diff --git a/src/core/infrastructure/cron/recalculate-contribution-amount.cron.ts b/src/core/infrastructure/cron/recalculate-contribution-amount.cron.ts index 1a84d94..e57e9e5 100644 --- a/src/core/infrastructure/cron/recalculate-contribution-amount.cron.ts +++ b/src/core/infrastructure/cron/recalculate-contribution-amount.cron.ts @@ -1,25 +1,32 @@ import { CronJob } from "cron"; import { PgGoalContributionRepository } from "../../../features/goals/infrastucture/adapters/goal-contribution.repository"; -import { EmailService } from "../../../features/email/application/services/email.service"; -import { PgUserRepository } from "../../../features/users/infrastructure/adapters/user.repository"; import { PgGoalRepository } from "../../../features/goals/infrastucture/adapters/goal.repository"; import { GoalSuggestionService } from "../../../features/goals/application/services/goal-suggestion.service"; import { NotificationUtilsService } from "../../../features/notifications/application/services/notification-utils.service"; import { PgNotificationRepository } from "../../../features/notifications/infrastructure/adapters/notification.repository"; import { NotificationType } from "../../../features/notifications/domain/entities/INotification"; -// Recalculate contribution amount cron job that runs every day at 8am UTC-5 +let isRunning = false; + +/** + * Recalculate contribution amount for inactive goals and generate suggestions. + * Runs once per day at 8 AM (America/Bogota). + */ export const recalculateContributionAmountCron = new CronJob( - // runs every 2 minutes - "*/1 * * * *", + "0 8 * * *", async () => { + if (isRunning) { + return; + } + try { + isRunning = true; console.log("Starting contribution amount recalculation job"); - // Inicializar servicios const goalSuggestionService = GoalSuggestionService.getInstance(); + const notificationRepository = PgNotificationRepository.getInstance(); const notificationUtils = NotificationUtilsService.getInstance( - PgNotificationRepository.getInstance() + notificationRepository ); // Find goals where last contribution was more than 1 week ago @@ -29,46 +36,50 @@ export const recalculateContributionAmountCron = new CronJob( console.log(`Found ${goalsToUpdate.length} goals to update`); // Check all active goals for other suggestions - const allActiveGoals = await PgGoalRepository.getInstance().findAllActive(); - console.log(`Found ${allActiveGoals.length} active goals to check for suggestions`); + const allActiveGoals = + await PgGoalRepository.getInstance().findAllActive(); + console.log( + `Found ${allActiveGoals.length} active goals to check for suggestions` + ); // Process all active goals for suggestions for (const goal of allActiveGoals) { - // Check for goal at risk await goalSuggestionService.checkGoalAtRisk(goal); - - // Check for inactivity await goalSuggestionService.checkInactivity(goal); - + // Weekly saving suggestion (only for goals that haven't been updated in contribution amount) - if (!goalsToUpdate.some(g => g.id === goal.id)) { + if (!goalsToUpdate.some((g) => g.id === goal.id)) { await goalSuggestionService.suggestWeeklySaving(goal); } - + // Optimize saving plan suggestion (run less frequently, e.g. after 5+ contributions) - const contributions = await PgGoalContributionRepository.getInstance().findByGoalId(goal.id); + const contributions = + await PgGoalContributionRepository.getInstance().findByGoalId( + goal.id + ); if (contributions.length >= 5) { // Check if we've sent this suggestion recently (in the last 14 days) - const recentSuggestions = await PgNotificationRepository.getInstance().findByUserIdAndType( - goal.userId, - NotificationType.SUGGESTION, - new Date(Date.now() - 14 * 24 * 60 * 60 * 1000) - ); - - const hasSentOptimizationRecently = recentSuggestions.some(notification => - notification.title.includes("Optimiza tu plan de ahorro") && - notification.title.includes(goal.name) + const recentSuggestions = + await notificationRepository.findByUserIdAndType( + goal.userId, + NotificationType.SUGGESTION, + new Date(Date.now() - 14 * 24 * 60 * 60 * 1000) + ); + + const hasSentOptimizationRecently = recentSuggestions.some( + (notification) => + notification.title.includes("Optimiza tu plan de ahorro") && + notification.title.includes(goal.name) ); - + if (!hasSentOptimizationRecently) { await goalSuggestionService.suggestOptimizedSaving(goal); } } } - // Now process the original recalculation logic + // Process recalculation logic and notifications for inactive goals for (const goal of goalsToUpdate) { - // Get the latest contribution for this goal const latestContribution = await PgGoalContributionRepository.getInstance().findLatestContribution( goal.id @@ -76,65 +87,89 @@ export const recalculateContributionAmountCron = new CronJob( const lastContributionDate = latestContribution?.date; - // Check if the last contribution was more than a week ago or if there's no contribution yet - if (lastContributionDate) { - // Recalculate the contribution amount based on remaining amount, time, and frequency - const today = new Date(); - const daysRemaining = Math.ceil( - (goal.endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24) - ); + if (!lastContributionDate) { + continue; + } - // Adjust for contribution frequency (e.g., 7 for weekly, 30 for monthly) - const contributionFrequency = goal.contributionFrequency || 7; // Default to weekly if not set - const contributionsRemaining = Math.ceil( - daysRemaining / contributionFrequency - ); + // Recalculate the contribution amount based on remaining amount, time, and frequency + const today = new Date(); + const daysRemaining = Math.ceil( + (goal.endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24) + ); - if (contributionsRemaining <= 0) { - console.log( - `Goal ${goal.id} has no contributions remaining, skipping recalculation` - ); - continue; - } + const contributionFrequency = goal.contributionFrequency || 7; // Default to weekly if not set + const contributionsRemaining = Math.ceil( + daysRemaining / contributionFrequency + ); + + if (contributionsRemaining <= 0) { + console.log( + `Goal ${goal.id} has no contributions remaining, skipping recalculation` + ); + continue; + } - const amountRemaining = - Number(goal.targetAmount) - Number(goal.currentAmount); - const newContributionAmount = - amountRemaining / contributionsRemaining; + const amountRemaining = + Number(goal.targetAmount) - Number(goal.currentAmount); + const newContributionAmount = amountRemaining / contributionsRemaining; - // Update the goal with the new contribution amount - await PgGoalRepository.getInstance().update(goal.id, { - contributionAmount: Number(newContributionAmount.toFixed(2)), - }); + // Update the goal with the new contribution amount + await PgGoalRepository.getInstance().update(goal.id, { + contributionAmount: Number(newContributionAmount.toFixed(2)), + }); - // Create notification using the notification utils service - await notificationUtils.createSuggestionNotification( + // Avoid sending duplicate recalculation suggestions too frequently + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const recentRecalculationSuggestions = + await notificationRepository.findByUserIdAndType( goal.userId, - `Meta: ${goal.name} - Aporte recalculado`, - `No has realizado aportes a tu meta "${goal.name}" en más de una semana. Tu monto de aporte sugerido ha sido recalculado a $${newContributionAmount.toFixed(2)} para que puedas alcanzar tu objetivo a tiempo. Monto restante: $${amountRemaining.toFixed(2)}.`, - true // Send email + NotificationType.SUGGESTION, + oneDayAgo + ); + + const hasRecentRecalculationSuggestion = + recentRecalculationSuggestions.some( + (notification) => + notification.title.includes("Meta:") && + notification.title.includes(goal.name) && + notification.title.includes("Aporte recalculado") ); + if (hasRecentRecalculationSuggestion) { console.log( - `Recalculated contribution for goal ${ - goal.id - } to $${newContributionAmount.toFixed(2)}` + `Skipping recalculation notification for goal ${goal.id} (already sent recently)` ); + continue; } + + await notificationUtils.createSuggestionNotification( + goal.userId, + `Meta: ${goal.name} - Aporte recalculado`, + `No has realizado aportes a tu meta "${ + goal.name + }" en más de una semana. Tu monto de aporte sugerido ha sido recalculado a $${newContributionAmount.toFixed( + 2 + )} para que puedas alcanzar tu objetivo a tiempo. Monto restante: $${amountRemaining.toFixed( + 2 + )}.`, + true + ); + + console.log( + `Recalculated contribution for goal ${ + goal.id + } to $${newContributionAmount.toFixed(2)}` + ); } console.log("Contribution amount recalculation job completed"); } catch (error) { console.error("Error in contribution amount recalculation job:", error); + } finally { + isRunning = false; } }, - null, // onComplete - false, // start - "America/Bogota" // Timezone UTC-5 + null, + false, + "America/Bogota" ); - -// Helper function to get user email from user ID -async function getUserEmail(userId: number): Promise { - const result = await PgUserRepository.getInstance().findById(userId); - return result?.email || ""; -} \ No newline at end of file diff --git a/src/core/infrastructure/database/schema.ts b/src/core/infrastructure/database/schema.ts index 99d7fbd..c630118 100644 --- a/src/core/infrastructure/database/schema.ts +++ b/src/core/infrastructure/database/schema.ts @@ -26,6 +26,9 @@ export const users = pgTable("users", { active: boolean("active").default(true).notNull(), recovery_token: varchar("recovery_token"), recovery_token_expires: timestamp("recovery_token_expires"), + recommendations_enabled: boolean("recommendations_enabled") + .default(false) + .notNull(), created_at: timestamp("created_at") .default(sql`CURRENT_TIMESTAMP`) .notNull(), @@ -384,3 +387,85 @@ export const reports = pgTable("reports", { .notNull(), expires_at: timestamp("expires_at").notNull(), }); + +export const recommendations = pgTable( + "recommendations", + { + id: serial("id").primaryKey(), + user_id: integer("user_id") + .references(() => users.id) + .notNull(), + type: varchar("type", { length: 50 }).notNull(), + priority: varchar("priority", { length: 20 }).notNull(), + title: varchar("title", { length: 255 }).notNull(), + description: text("description").notNull(), + data: jsonb("data"), + actionable: boolean("actionable").default(false).notNull(), + actions: jsonb("actions"), + status: varchar("status", { length: 20 }).default("PENDING").notNull(), + created_at: timestamp("created_at") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + expires_at: timestamp("expires_at"), + viewed_at: timestamp("viewed_at"), + dismissed_at: timestamp("dismissed_at"), + acted_at: timestamp("acted_at"), + }, + (table) => { + return { + user_idx: index("rec_user_idx").on(table.user_id), + status_idx: index("rec_status_idx").on(table.status), + type_idx: index("rec_type_idx").on(table.type), + created_idx: index("rec_created_idx").on(table.created_at), + }; + } +); + +export const plans = pgTable("plans", { + id: serial("id").primaryKey(), + name: varchar("name").notNull(), + duration_days: integer("duration_days").notNull(), + price: decimal("price", { precision: 10, scale: 2 }).notNull(), + frequency: varchar("frequency").notNull(), + description: text("description"), + features: jsonb("features").$type(), + created_at: timestamp("created_at") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updated_at: timestamp("updated_at") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), +}); + +export const subscriptions = pgTable( + "subscriptions", + { + id: serial("id").primaryKey(), + user_id: integer("user_id") + .references(() => users.id) + .notNull(), + plan_id: integer("plan_id") + .references(() => plans.id) + .notNull(), + frequency: varchar("frequency").notNull(), + start_date: timestamp("start_date") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + end_date: timestamp("end_date").notNull(), + retirement_date: timestamp("retirement_date"), + active: boolean("active").default(true).notNull(), + created_at: timestamp("created_at") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updated_at: timestamp("updated_at") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => { + return { + user_idx: index("sub_user_idx").on(table.user_id), + plan_idx: index("sub_plan_idx").on(table.plan_id), + active_idx: index("sub_active_idx").on(table.active), + }; + } +); diff --git a/src/core/infrastructure/scripts/assign-demo.ts b/src/core/infrastructure/scripts/assign-demo.ts new file mode 100644 index 0000000..13309da --- /dev/null +++ b/src/core/infrastructure/scripts/assign-demo.ts @@ -0,0 +1,35 @@ +import DatabaseConnection from "@/core/infrastructure/database"; +import { subscriptions, plans } from "@/schema"; +import { eq } from "drizzle-orm"; + +async function assignDemoPlan(userId: number) { + const db = DatabaseConnection.getInstance().db; + + // Find Plan Demo + const demoPlan = await db.query.plans.findFirst({ + where: eq(plans.name, "Plan Demo") + }); + + if (!demoPlan) { + console.error("Plan Demo not found!"); + process.exit(1); + } + + const startDate = new Date(); + const endDate = new Date(); + endDate.setDate(endDate.getDate() + demoPlan.duration_days); + + await db.insert(subscriptions).values({ + user_id: userId, + plan_id: demoPlan.id, + frequency: demoPlan.frequency, + start_date: startDate, + end_date: endDate, + active: true, + }); + + console.log(`Assigned Plan Demo to user ${userId}`); + await DatabaseConnection.getInstance().close(); +} + +assignDemoPlan(1); diff --git a/src/core/infrastructure/scripts/clear-plans.ts b/src/core/infrastructure/scripts/clear-plans.ts new file mode 100644 index 0000000..23f1e59 --- /dev/null +++ b/src/core/infrastructure/scripts/clear-plans.ts @@ -0,0 +1,12 @@ +import DatabaseConnection from "@/core/infrastructure/database"; +import { plans } from "@/schema"; +import { sql } from "drizzle-orm"; + +async function clearPlans() { + const db = DatabaseConnection.getInstance().db; + await db.execute(sql`TRUNCATE TABLE ${plans} RESTART IDENTITY CASCADE`); + console.log("Plans table truncated."); + await DatabaseConnection.getInstance().close(); +} + +clearPlans(); diff --git a/src/core/infrastructure/scripts/list-plans.ts b/src/core/infrastructure/scripts/list-plans.ts new file mode 100644 index 0000000..fae410d --- /dev/null +++ b/src/core/infrastructure/scripts/list-plans.ts @@ -0,0 +1,11 @@ +import DatabaseConnection from "@/core/infrastructure/database"; +import { plans } from "@/schema"; + +async function listPlans() { + const db = DatabaseConnection.getInstance().db; + const allPlans = await db.select().from(plans); + console.log(JSON.stringify(allPlans, null, 2)); + await DatabaseConnection.getInstance().close(); +} + +listPlans(); diff --git a/src/core/infrastructure/scripts/list-subscriptions.ts b/src/core/infrastructure/scripts/list-subscriptions.ts new file mode 100644 index 0000000..8f09bed --- /dev/null +++ b/src/core/infrastructure/scripts/list-subscriptions.ts @@ -0,0 +1,24 @@ +import DatabaseConnection from "@/core/infrastructure/database"; +import { subscriptions, users, plans } from "@/schema"; +import { eq } from "drizzle-orm"; + +async function listSubscriptions() { + const db = DatabaseConnection.getInstance().db; + const subs = await db + .select({ + subscriptionId: subscriptions.id, + user: users.email, + plan: plans.name, + active: subscriptions.active, + startDate: subscriptions.start_date, + endDate: subscriptions.end_date, + }) + .from(subscriptions) + .leftJoin(users, eq(subscriptions.user_id, users.id)) + .leftJoin(plans, eq(subscriptions.plan_id, plans.id)); + + console.log(JSON.stringify(subs, null, 2)); + await DatabaseConnection.getInstance().close(); +} + +listSubscriptions(); diff --git a/src/core/infrastructure/scripts/list-users.ts b/src/core/infrastructure/scripts/list-users.ts new file mode 100644 index 0000000..bbf3eb1 --- /dev/null +++ b/src/core/infrastructure/scripts/list-users.ts @@ -0,0 +1,11 @@ +import DatabaseConnection from "@/core/infrastructure/database"; +import { users } from "@/schema"; + +async function listUsers() { + const db = DatabaseConnection.getInstance().db; + const allUsers = await db.select().from(users); + console.log(JSON.stringify(allUsers, null, 2)); + await DatabaseConnection.getInstance().close(); +} + +listUsers(); diff --git a/src/core/infrastructure/scripts/plans.seed.ts b/src/core/infrastructure/scripts/plans.seed.ts new file mode 100644 index 0000000..8eb560f --- /dev/null +++ b/src/core/infrastructure/scripts/plans.seed.ts @@ -0,0 +1,122 @@ +import DatabaseConnection from "@/core/infrastructure/database"; +import { plans } from "@/schema"; + +async function seedPlans() { + console.log("🌱 Iniciando seeding de planes..."); + + const db = DatabaseConnection.getInstance().db; + + try { + const existingPlans = await db.select().from(plans); + + if (existingPlans.length > 0) { + console.log(`Ya existen ${existingPlans.length} planes en la base de datos.`); + return; + } + + const plansData = [ + { + name: "Plan Demo", + durationDays: 15, + price: "0.00", + frequency: "one-time", + description: "Prueba las funcionalidades básicas", + features: ["Acceso limitado", "Prueba de concepto"] + }, + { + name: "Plan Lite", + durationDays: 30, + price: "9.99", + frequency: "monthly", + description: "Perfecto para comenzar con la gestión financiera inteligente", + features: [ + "Entrada de voz", + "Acceso ilimitado a IA", + "Soporte básico", + "Acceso a la app móvil", + "Informes exportables" + ] + }, + { + name: "Plan Lite Anual", + durationDays: 365, + price: "99.99", + frequency: "yearly", + description: "Perfecto para comenzar con la gestión financiera inteligente", + features: [ + "Entrada de voz", + "Acceso ilimitado a IA", + "Soporte básico", + "Acceso a la app móvil", + "Informes exportables" + ] + }, + { + name: "Plan Plus", + durationDays: 30, + price: "19.99", + frequency: "monthly", + description: "Para usuarios avanzados que quieren insights avanzados y soporte prioritario", + features: [ + "Entrada de voz", + "Acceso ilimitado a IA", + "Recomendaciones avanzadas", + "Informes detallados", + "Soporte prioritario", + "Categorías personalizadas", + "Alertas de presupuesto", + "Sincronización multi-dispositivo" + ] + }, + { + name: "Plan Plus Anual", + durationDays: 365, + price: "199.99", + frequency: "yearly", + description: "Para usuarios avanzados que quieren insights avanzados y soporte prioritario", + features: [ + "Entrada de voz", + "Acceso ilimitado a IA", + "Recomendaciones avanzadas", + "Informes detallados", + "Soporte prioritario", + "Categorías personalizadas", + "Alertas de presupuesto", + "Sincronización multi-dispositivo" + ] + }, + ]; + + console.log(`Creando ${plansData.length} planes...`); + + for (const plan of plansData) { + await db.insert(plans).values({ + name: plan.name, + duration_days: plan.durationDays, + price: plan.price, + frequency: plan.frequency, + description: plan.description, + features: plan.features, + }); + } + + console.log("✅ Planes creados exitosamente!"); + + } catch (error) { + console.error("❌ Error durante la creación de planes:", error); + } finally { + await DatabaseConnection.getInstance().close(); + } +} + +if (require.main === module) { + seedPlans() + .then(() => process.exit(0)) + .catch((error) => { + console.error("❌ Error fatal durante el seeding de planes:", error); + process.exit(1); + }); +} + +export default seedPlans; + diff --git a/src/features/ai-agents/tests/voice-orchestrator.service.test.ts b/src/features/ai-agents/tests/voice-orchestrator.service.test.ts new file mode 100644 index 0000000..b6ff660 --- /dev/null +++ b/src/features/ai-agents/tests/voice-orchestrator.service.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { VoiceOrchestratorService } from '../application/services/voice-orchestrator.service'; +import { ITranscriptionService, ILLMService } from '../domain/ports/transcription.port'; +import { TransactionAgentService } from '../application/services/transaction-agent.service'; +import { GoalAgentService } from '../application/services/goal-agent.service'; +import { BudgetAgentService } from '../application/services/budget-agent.service'; +import { ValidationAgentService } from '../application/services/validation-agent.service'; + +describe('VoiceOrchestratorService', () => { + let voiceOrchestratorService: VoiceOrchestratorService; + let transcriptionServiceMock: ITranscriptionService; + let llmServiceMock: ILLMService; + let transactionAgentMock: TransactionAgentService; + let goalAgentMock: GoalAgentService; + let budgetAgentMock: BudgetAgentService; + let validationAgentMock: ValidationAgentService; + + beforeEach(() => { + transcriptionServiceMock = { + transcribe: vi.fn(), + } as any; + + llmServiceMock = { + classifyIntent: vi.fn(), + extractData: vi.fn(), + } as any; + + transactionAgentMock = { + processTransaction: vi.fn(), + } as any; + + goalAgentMock = { + processGoal: vi.fn(), + } as any; + + budgetAgentMock = { + processBudget: vi.fn(), + } as any; + + validationAgentMock = { + validateResponse: vi.fn(), + } as any; + + voiceOrchestratorService = new VoiceOrchestratorService( + transcriptionServiceMock, + llmServiceMock, + transactionAgentMock, + goalAgentMock, + budgetAgentMock, + validationAgentMock + ); + }); + + describe('processVoiceCommand', () => { + it('should return error if intent confidence is low', async () => { + (transcriptionServiceMock.transcribe as any).mockResolvedValue('test'); + (llmServiceMock.classifyIntent as any).mockResolvedValue({ intent: 'CREATE_TRANSACTION', confidence: 0.5 }); + + const result = await voiceOrchestratorService.processVoiceCommand({} as any, 1); + + expect(result.success).toBe(false); + expect(result.message).toBe('No pude entender tu solicitud. ¿Podrías repetirla de otra manera?'); + }); + + it('should process transaction command', async () => { + (transcriptionServiceMock.transcribe as any).mockResolvedValue('test'); + (llmServiceMock.classifyIntent as any).mockResolvedValue({ intent: 'CREATE_TRANSACTION', confidence: 0.9 }); + (llmServiceMock.extractData as any).mockResolvedValue({ data: {}, confidence: 0.9 }); + (transactionAgentMock.processTransaction as any).mockResolvedValue({ success: true }); + (validationAgentMock.validateResponse as any).mockResolvedValue({ success: true }); + + const result = await voiceOrchestratorService.processVoiceCommand({} as any, 1); + + expect(result.success).toBe(true); + expect(transactionAgentMock.processTransaction).toHaveBeenCalled(); + }); + + it('should process goal command', async () => { + (transcriptionServiceMock.transcribe as any).mockResolvedValue('test'); + (llmServiceMock.classifyIntent as any).mockResolvedValue({ intent: 'CREATE_GOAL', confidence: 0.9 }); + (llmServiceMock.extractData as any).mockResolvedValue({ data: {}, confidence: 0.9 }); + (goalAgentMock.processGoal as any).mockResolvedValue({ success: true }); + (validationAgentMock.validateResponse as any).mockResolvedValue({ success: true }); + + const result = await voiceOrchestratorService.processVoiceCommand({} as any, 1); + + expect(result.success).toBe(true); + expect(goalAgentMock.processGoal).toHaveBeenCalled(); + }); + + it('should process budget command', async () => { + (transcriptionServiceMock.transcribe as any).mockResolvedValue('test'); + (llmServiceMock.classifyIntent as any).mockResolvedValue({ intent: 'CREATE_BUDGET', confidence: 0.9 }); + (llmServiceMock.extractData as any).mockResolvedValue({ data: {}, confidence: 0.9 }); + (budgetAgentMock.processBudget as any).mockResolvedValue({ success: true }); + (validationAgentMock.validateResponse as any).mockResolvedValue({ success: true }); + + const result = await voiceOrchestratorService.processVoiceCommand({} as any, 1); + + expect(result.success).toBe(true); + expect(budgetAgentMock.processBudget).toHaveBeenCalled(); + }); + + it('should return error if intent is unknown', async () => { + (transcriptionServiceMock.transcribe as any).mockResolvedValue('test'); + (llmServiceMock.classifyIntent as any).mockResolvedValue({ intent: 'UNKNOWN_INTENT', confidence: 0.9 }); + (llmServiceMock.extractData as any).mockResolvedValue({ data: {}, confidence: 0.9 }); + + const result = await voiceOrchestratorService.processVoiceCommand({} as any, 1); + + expect(result.success).toBe(false); + expect(result.message).toBe('Tipo de comando no soportado'); + }); + + it('should handle errors gracefully', async () => { + (transcriptionServiceMock.transcribe as any).mockRejectedValue(new Error('Error')); + + const result = await voiceOrchestratorService.processVoiceCommand({} as any, 1); + + expect(result.success).toBe(false); + expect(result.message).toBe('Ocurrió un error procesando tu solicitud'); + }); + }); +}); diff --git a/src/features/auth/application/dtos/auth.dto.ts b/src/features/auth/application/dtos/auth.dto.ts index ff6ee4b..73ff982 100644 --- a/src/features/auth/application/dtos/auth.dto.ts +++ b/src/features/auth/application/dtos/auth.dto.ts @@ -26,6 +26,24 @@ export const authResponseSchema = z.object({ token: z.string(), }); +export const forgotPasswordSchema = z.object({ + email: z.string().email("Invalid email format"), +}); + +export const resetPasswordSchema = z.object({ + token: z.string().min(1, "Token is required"), + password: z + .string() + .min(8, "Password must be at least 8 characters") + .max(100, "Password must not exceed 100 characters") + .regex(/[A-Z]/, "Password must contain at least one uppercase letter") + .regex(/[a-z]/, "Password must contain at least one lowercase letter") + .regex(/[0-9]/, "Password must contain at least one number") + .regex(/[@$!%*?&.]/, "Password must contain at least one special character"), +}); + export type LoginDTO = z.infer; export type RegisterDTO = z.infer; export type AuthResponse = z.infer; +export type ForgotPasswordDTO = z.infer; +export type ResetPasswordDTO = z.infer; diff --git a/src/features/auth/application/services/auth.service.ts b/src/features/auth/application/services/auth.service.ts index 53ccf70..fd2870a 100644 --- a/src/features/auth/application/services/auth.service.ts +++ b/src/features/auth/application/services/auth.service.ts @@ -3,15 +3,24 @@ import { createHandler } from "@/core/infrastructure/lib/handler.wrapper,"; import { LoginRoute, RegisterRoute, + ForgotPasswordRoute, + ResetPasswordRoute, } from "@/auth/infrastructure/controllers/auth.routes"; import { hash, verify } from "@/shared/utils/crypto.util"; import { generateToken } from "@/shared/utils/jwt.util"; +import { EmailService } from "@/email/application/services/email.service"; import * as HttpStatusCodes from "stoker/http-status-codes"; +import { randomBytes } from "crypto"; +import { PgSubscriptionRepository } from "@/subscriptions/infrastructure/adapters/subscription.repository"; +import { PgPlanRepository } from "@/subscriptions/infrastructure/adapters/plan.repository"; export class AuthService { private static instance: AuthService; + private emailService: EmailService; - constructor(private readonly userRepository: IUserRepository) {} + constructor(private readonly userRepository: IUserRepository) { + this.emailService = EmailService.getInstance(); + } public static getInstance(userRepository: IUserRepository): AuthService { if (!AuthService.instance) { @@ -110,6 +119,24 @@ export class AuthService { active: true, }); + // Asignar plan demo + const planRepository = PgPlanRepository.getInstance(); + const subscriptionRepository = PgSubscriptionRepository.getInstance(); + const demoPlan = await planRepository.findByName("Plan Demo"); + if (demoPlan) { + const startDate = new Date(); + const endDate = new Date(); + endDate.setDate(endDate.getDate() + demoPlan.durationDays); + await subscriptionRepository.create({ + userId: user.id, + planId: demoPlan.id, + frequency: demoPlan.frequency, + startDate, + endDate, + active: true, + }); + } + // Generar token const token = await generateToken({ id: user.id, @@ -131,4 +158,129 @@ export class AuthService { HttpStatusCodes.CREATED ); }); + + forgotPassword = createHandler(async (c) => { + const { email } = c.req.valid("json"); + + const user = await this.userRepository.findByEmail(email); + if (!user || !user.active) { + return c.json( + { + success: false, + data: null, + message: "If the email exists, a recovery link will be sent", + }, + HttpStatusCodes.OK + ); + } + + const token = randomBytes(32).toString("hex"); + const expires = new Date(Date.now() + 3600000); + + await this.userRepository.setRecoveryToken(user.id, token, expires); + + const resetLink = `${process.env.FRONTEND_URL || "http://localhost:3001"}/reset-password?token=${token}`; + + const emailContent = ` + + + + + + + +
+
+

🔐 FoppyAI

+

Recuperación de Contraseña

+
+
+

Hola ${user.name},

+

Hemos recibido una solicitud para restablecer la contraseña de tu cuenta en FoppyAI.

+

Para continuar con el proceso, haz clic en el siguiente botón:

+
+ Restablecer Contraseña +
+

O copia y pega este enlace en tu navegador:

+

${resetLink}

+

Este enlace expirará en 1 hora.

+

Si no solicitaste este cambio, puedes ignorar este correo. Tu contraseña permanecerá sin cambios.

+
+ +
+ + + `; + + try { + await this.emailService.sendSimpleEmail( + email, + "Recuperación de Contraseña - FoppyAI", + emailContent, + { isHtml: true } + ); + } catch (error) { + console.error("Error sending email:", error); + } + + return c.json( + { + success: true, + data: null, + message: "If the email exists, a recovery link will be sent", + }, + HttpStatusCodes.OK + ); + }); + + resetPassword = createHandler(async (c) => { + const { token, password } = c.req.valid("json"); + + const user = await this.userRepository.findByRecoveryToken(token); + if (!user) { + return c.json( + { + success: false, + data: null, + message: "Invalid or expired token", + }, + HttpStatusCodes.BAD_REQUEST + ); + } + + if (!user.recoveryTokenExpires || user.recoveryTokenExpires < new Date()) { + await this.userRepository.clearRecoveryToken(user.id); + return c.json( + { + success: false, + data: null, + message: "Token has expired. Please request a new password reset", + }, + HttpStatusCodes.BAD_REQUEST + ); + } + + const passwordHash = await hash(password); + await this.userRepository.update(user.id, { passwordHash }); + await this.userRepository.clearRecoveryToken(user.id); + + return c.json( + { + success: true, + data: null, + message: "Password has been reset successfully", + }, + HttpStatusCodes.OK + ); + }); } diff --git a/src/features/auth/infrastructure/controllers/auth.controller.ts b/src/features/auth/infrastructure/controllers/auth.controller.ts index aa9603b..d92fc5c 100644 --- a/src/features/auth/infrastructure/controllers/auth.controller.ts +++ b/src/features/auth/infrastructure/controllers/auth.controller.ts @@ -8,6 +8,8 @@ const authService = AuthService.getInstance(userRepository); const router = createRouter() .openapi(routes.login, authService.login) - .openapi(routes.register, authService.register); + .openapi(routes.register, authService.register) + .openapi(routes.forgotPassword, authService.forgotPassword) + .openapi(routes.resetPassword, authService.resetPassword); export default router; diff --git a/src/features/auth/infrastructure/controllers/auth.routes.ts b/src/features/auth/infrastructure/controllers/auth.routes.ts index e18c5bc..828e72d 100644 --- a/src/features/auth/infrastructure/controllers/auth.routes.ts +++ b/src/features/auth/infrastructure/controllers/auth.routes.ts @@ -5,6 +5,8 @@ import { authResponseSchema, loginSchema, registerSchema, + forgotPasswordSchema, + resetPasswordSchema, } from "../../application/dtos/auth.dto"; import { z } from "zod"; @@ -85,5 +87,69 @@ export const register = createRoute({ }, }); +export const forgotPassword = createRoute({ + path: "/auth/forgot-password", + method: "post", + tags, + request: { + body: jsonContentRequired(forgotPasswordSchema, "Forgot password request"), + }, + responses: { + [HttpStatusCodes.OK]: { + content: { + "application/json": { + schema: z.object({ + success: z.boolean(), + data: z.null(), + message: z.string(), + }), + }, + }, + description: "Password recovery email sent", + }, + [HttpStatusCodes.NOT_FOUND]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "User not found", + }, + }, +}); + +export const resetPassword = createRoute({ + path: "/auth/reset-password", + method: "post", + tags, + request: { + body: jsonContentRequired(resetPasswordSchema, "Reset password request"), + }, + responses: { + [HttpStatusCodes.OK]: { + content: { + "application/json": { + schema: z.object({ + success: z.boolean(), + data: z.null(), + message: z.string(), + }), + }, + }, + description: "Password reset successful", + }, + [HttpStatusCodes.BAD_REQUEST]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Invalid or expired token", + }, + }, +}); + export type LoginRoute = typeof login; export type RegisterRoute = typeof register; +export type ForgotPasswordRoute = typeof forgotPassword; +export type ResetPasswordRoute = typeof resetPassword; diff --git a/src/features/auth/tests/auth.service.test.ts b/src/features/auth/tests/auth.service.test.ts new file mode 100644 index 0000000..ae9593e --- /dev/null +++ b/src/features/auth/tests/auth.service.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AuthService } from '../application/services/auth.service'; +import { IUserRepository } from '@/users/domain/ports/user-repository.port'; +import * as HttpStatusCodes from 'stoker/http-status-codes'; +import { hash, verify } from '@/shared/utils/crypto.util'; +import { generateToken } from '@/shared/utils/jwt.util'; +import { EmailService } from '@/email/application/services/email.service'; +import { PgPlanRepository } from '@/subscriptions/infrastructure/adapters/plan.repository'; +import { PgSubscriptionRepository } from '@/subscriptions/infrastructure/adapters/subscription.repository'; + +// Mocks +vi.mock('@/shared/utils/crypto.util', () => ({ + hash: vi.fn(), + verify: vi.fn(), +})); +vi.mock('@/shared/utils/jwt.util', () => ({ + generateToken: vi.fn(), +})); +vi.mock('@/email/application/services/email.service', () => ({ + EmailService: { + getInstance: vi.fn(), + }, +})); +vi.mock('@/subscriptions/infrastructure/adapters/plan.repository', () => ({ + PgPlanRepository: { + getInstance: vi.fn(), + }, +})); +vi.mock('@/subscriptions/infrastructure/adapters/subscription.repository', () => ({ + PgSubscriptionRepository: { + getInstance: vi.fn(), + }, +})); + +describe('AuthService', () => { + let authService: AuthService; + let userRepositoryMock: IUserRepository; + let emailServiceMock: any; + let planRepositoryMock: any; + let subscriptionRepositoryMock: any; + + beforeEach(() => { + userRepositoryMock = { + findByEmail: vi.fn(), + findByUsername: vi.fn(), + create: vi.fn(), + setRecoveryToken: vi.fn(), + findByRecoveryToken: vi.fn(), + clearRecoveryToken: vi.fn(), + update: vi.fn(), + } as any; + + emailServiceMock = { + sendSimpleEmail: vi.fn(), + }; + (EmailService.getInstance as any) = vi.fn().mockReturnValue(emailServiceMock); + + planRepositoryMock = { + findByName: vi.fn(), + }; + (PgPlanRepository.getInstance as any) = vi.fn().mockReturnValue(planRepositoryMock); + + subscriptionRepositoryMock = { + create: vi.fn(), + }; + (PgSubscriptionRepository.getInstance as any) = vi.fn().mockReturnValue(subscriptionRepositoryMock); + + // Reset singleton instance + (AuthService as any).instance = null; + authService = AuthService.getInstance(userRepositoryMock); + }); + + describe('login', () => { + it('should return 400 if user not found', async () => { + (userRepositoryMock.findByEmail as any).mockResolvedValue(null); + const c = { + req: { valid: vi.fn().mockReturnValue({ email: 'test@example.com', password: 'password' }) }, + json: vi.fn(), + }; + + await authService.login(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Invalid credentials' }), + HttpStatusCodes.BAD_REQUEST + ); + }); + + it('should return 400 if password invalid', async () => { + (userRepositoryMock.findByEmail as any).mockResolvedValue({ + id: '1', + email: 'test@example.com', + passwordHash: 'hashed', + active: true, + }); + (verify as any).mockResolvedValue(false); + const c = { + req: { valid: vi.fn().mockReturnValue({ email: 'test@example.com', password: 'password' }) }, + json: vi.fn(), + }; + + await authService.login(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Invalid credentials' }), + HttpStatusCodes.BAD_REQUEST + ); + }); + + it('should return 200 and token if login successful', async () => { + (userRepositoryMock.findByEmail as any).mockResolvedValue({ + id: '1', + email: 'test@example.com', + passwordHash: 'hashed', + active: true, + name: 'Test', + username: 'test', + }); + (verify as any).mockResolvedValue(true); + (generateToken as any).mockResolvedValue('token'); + const c = { + req: { valid: vi.fn().mockReturnValue({ email: 'test@example.com', password: 'password' }) }, + json: vi.fn(), + }; + + await authService.login(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ token: 'token' }), + }), + HttpStatusCodes.OK + ); + }); + }); + + describe('register', () => { + it('should return 409 if email exists', async () => { + (userRepositoryMock.findByEmail as any).mockResolvedValue({}); + const c = { + req: { valid: vi.fn().mockReturnValue({ email: 'test@example.com' }) }, + json: vi.fn(), + }; + + await authService.register(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Email already exists' }), + HttpStatusCodes.CONFLICT + ); + }); + + it('should return 201 if registration successful', async () => { + (userRepositoryMock.findByEmail as any).mockResolvedValue(null); + (userRepositoryMock.findByUsername as any).mockResolvedValue(null); + (hash as any).mockResolvedValue('hashed'); + (userRepositoryMock.create as any).mockResolvedValue({ + id: '1', + email: 'test@example.com', + name: 'Test', + username: 'test', + }); + (generateToken as any).mockResolvedValue('token'); + (planRepositoryMock.findByName as any).mockResolvedValue({ id: 'plan1', durationDays: 30, frequency: 'monthly' }); + + const c = { + req: { valid: vi.fn().mockReturnValue({ + email: 'test@example.com', + username: 'test', + password: 'password', + name: 'Test', + }) }, + json: vi.fn(), + }; + + await authService.register(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ token: 'token' }), + }), + HttpStatusCodes.CREATED + ); + expect(subscriptionRepositoryMock.create).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/features/budgets/tests/budget.service.test.ts b/src/features/budgets/tests/budget.service.test.ts new file mode 100644 index 0000000..17efd87 --- /dev/null +++ b/src/features/budgets/tests/budget.service.test.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock env before importing service +vi.mock('@/core/infrastructure/env/env', () => ({ + default: { + DATABASE_URL: 'postgres://user:pass@localhost:5432/db', + NODE_ENV: 'test', + LOG_LEVEL: 'info', + }, +})); + +import { BudgetService } from '../application/services/budget.service'; +import { IBudgetRepository } from '../domain/ports/budget-repository.port'; +import { BudgetUtilsService } from '../application/services/budget-utils.service'; +import { ITransactionRepository } from '@/transactions/domain/ports/transaction-repository.port'; +import { IPaymentMethodRepository } from '@/payment-methods/domain/ports/payment-method-repository.port'; +import { BudgetNotificationService } from '../application/services/budget-notification.service'; +import * as HttpStatusCodes from 'stoker/http-status-codes'; +import { BudgetApiAdapter } from '../infrastructure/adapters/budget-api.adapter'; +import { TransactionApiAdapter } from '@/transactions/infrastructure/adapters/transaction-api.adapter'; +import { PgTransactionRepository } from '@/transactions/infrastructure/adapters/transaction.repository'; + +// Mocks +vi.mock('../infrastructure/adapters/budget-api.adapter', () => ({ + BudgetApiAdapter: { + toApiResponseList: vi.fn((data) => data), + toApiResponse: vi.fn((data) => data), + }, +})); + +vi.mock('@/transactions/infrastructure/adapters/transaction-api.adapter', () => ({ + TransactionApiAdapter: { + toApiResponseList: vi.fn((data) => data), + }, +})); + +vi.mock('@/transactions/infrastructure/adapters/transaction.repository', () => ({ + PgTransactionRepository: { + getInstance: vi.fn(), + }, +})); + +vi.mock('../application/services/budget-notification.service', () => ({ + BudgetNotificationService: { + getInstance: vi.fn().mockReturnValue({ + notifyBudgetShared: vi.fn(), + checkBudgetLimits: vi.fn(), + }), + }, +})); + +describe('BudgetService', () => { + let budgetService: BudgetService; + let budgetRepositoryMock: IBudgetRepository; + let budgetUtilsMock: BudgetUtilsService; + let transactionRepositoryMock: ITransactionRepository; + let paymentMethodRepositoryMock: IPaymentMethodRepository; + let budgetNotificationServiceMock: BudgetNotificationService; + + beforeEach(() => { + budgetRepositoryMock = { + findAll: vi.fn(), + findById: vi.fn(), + findByUserId: vi.fn(), + findByUserIdAndMonth: vi.fn(), + findSharedWithUser: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + updateAmount: vi.fn(), + updateLimitAmount: vi.fn(), + } as any; + + budgetUtilsMock = { + validateUser: vi.fn(), + validateSharing: vi.fn(), + validateAmount: vi.fn(), + canAccess: vi.fn(), + validatePaymentMethod: vi.fn(), + } as any; + + transactionRepositoryMock = { + create: vi.fn(), + findByBudgetId: vi.fn(), + } as any; + + paymentMethodRepositoryMock = { + findById: vi.fn(), + } as any; + + budgetNotificationServiceMock = { + notifyBudgetShared: vi.fn(), + checkBudgetLimits: vi.fn(), + } as any; + + (PgTransactionRepository.getInstance as any).mockReturnValue(transactionRepositoryMock); + + (BudgetService as any).instance = null; + budgetService = BudgetService.getInstance( + budgetRepositoryMock, + budgetUtilsMock, + transactionRepositoryMock, + paymentMethodRepositoryMock, + budgetNotificationServiceMock + ); + }); + + describe('getAll', () => { + it('should return all budgets', async () => { + (budgetRepositoryMock.findAll as any).mockResolvedValue([]); + const c = { + json: vi.fn(), + }; + + await budgetService.getAll(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Budgets retrieved successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('getById', () => { + it('should return 404 if budget not found', async () => { + (budgetRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await budgetService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Budget not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return budget if found', async () => { + (budgetRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await budgetService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Budget retrieved successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('getByUserId', () => { + it('should return 404 if user not found', async () => { + (budgetUtilsMock.validateUser as any).mockResolvedValue({ isValid: false }); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await budgetService.getByUserId(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return user budgets if user found', async () => { + (budgetUtilsMock.validateUser as any).mockResolvedValue({ isValid: true }); + (budgetRepositoryMock.findByUserId as any).mockResolvedValue([]); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await budgetService.getByUserId(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'User budgets retrieved successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('create', () => { + it('should return 404 if user not found', async () => { + (budgetUtilsMock.validateUser as any).mockResolvedValue({ isValid: false }); + const c = { + req: { valid: vi.fn().mockReturnValue({ user_id: 1 }) }, + json: vi.fn(), + }; + + await budgetService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should create budget successfully', async () => { + (budgetUtilsMock.validateUser as any).mockResolvedValue({ isValid: true }); + (budgetRepositoryMock.create as any).mockResolvedValue({ id: 1 }); + const c = { + req: { valid: vi.fn().mockReturnValue({ user_id: 1, limit_amount: 100, month: '2023-01-01' }) }, + json: vi.fn(), + }; + + await budgetService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Budget created successfully' }), + HttpStatusCodes.CREATED + ); + }); + }); + + describe('update', () => { + it('should return 404 if budget not found', async () => { + (budgetRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1'), valid: vi.fn().mockReturnValue({}) }, + json: vi.fn(), + }; + + await budgetService.update(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Budget not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should update budget successfully', async () => { + (budgetRepositoryMock.findById as any).mockResolvedValue({ id: 1, userId: 1 }); + (budgetRepositoryMock.update as any).mockResolvedValue({ id: 1 }); + const c = { + req: { param: vi.fn().mockReturnValue('1'), valid: vi.fn().mockReturnValue({ limit_amount: 200 }) }, + json: vi.fn(), + }; + + await budgetService.update(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Budget updated successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('delete', () => { + it('should return 404 if budget not found', async () => { + (budgetRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await budgetService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Budget not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should delete budget successfully', async () => { + (budgetRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + (budgetRepositoryMock.delete as any).mockResolvedValue(true); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await budgetService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Budget deleted successfully' }), + HttpStatusCodes.OK + ); + }); + }); +}); diff --git a/src/features/categories/tests/category.service.test.ts b/src/features/categories/tests/category.service.test.ts new file mode 100644 index 0000000..3e36bea --- /dev/null +++ b/src/features/categories/tests/category.service.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CategoryService } from '../application/services/category.service'; +import { ICategoryRepository } from '../domain/ports/category-repository.port'; +import * as HttpStatusCodes from 'stoker/http-status-codes'; + +describe('CategoryService', () => { + let categoryService: CategoryService; + let categoryRepositoryMock: ICategoryRepository; + + beforeEach(() => { + categoryRepositoryMock = { + findAll: vi.fn(), + findById: vi.fn(), + findByName: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + } as any; + + (CategoryService as any).instance = null; + categoryService = CategoryService.getInstance(categoryRepositoryMock); + }); + + describe('getAll', () => { + it('should return all categories', async () => { + const categories = [{ id: 1, name: 'Food', description: 'Food items' }]; + (categoryRepositoryMock.findAll as any).mockResolvedValue(categories); + const c = { + json: vi.fn(), + }; + + await categoryService.getAll(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.arrayContaining([expect.objectContaining({ name: 'Food' })]), + }), + HttpStatusCodes.OK + ); + }); + }); + + describe('create', () => { + it('should return 409 if category exists', async () => { + (categoryRepositoryMock.findByName as any).mockResolvedValue({ id: 1, name: 'Food' }); + const c = { + req: { valid: vi.fn().mockReturnValue({ name: 'Food' }) }, + json: vi.fn(), + }; + + await categoryService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'A category with this name already exists' }), + HttpStatusCodes.CONFLICT + ); + }); + + it('should return 201 if category created', async () => { + (categoryRepositoryMock.findByName as any).mockResolvedValue(null); + (categoryRepositoryMock.create as any).mockResolvedValue({ id: 1, name: 'Food' }); + const c = { + req: { valid: vi.fn().mockReturnValue({ name: 'Food' }) }, + json: vi.fn(), + }; + + await categoryService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ name: 'Food' }), + }), + HttpStatusCodes.CREATED + ); + }); + }); +}); diff --git a/src/features/debts/infrastructure/adapters/debt.repository.ts b/src/features/debts/infrastructure/adapters/debt.repository.ts index ff17b6d..1530cab 100644 --- a/src/features/debts/infrastructure/adapters/debt.repository.ts +++ b/src/features/debts/infrastructure/adapters/debt.repository.ts @@ -5,242 +5,248 @@ import { IDebt } from "@/debts/domain/entities/IDebt"; import { IDebtRepository } from "@/debts/domain/ports/debt-repository.port"; export class PgDebtRepository implements IDebtRepository { - private db = DatabaseConnection.getInstance().db; - private static instance: PgDebtRepository; - - private constructor() {} - - public static getInstance(): PgDebtRepository { - if (!PgDebtRepository.instance) { - PgDebtRepository.instance = new PgDebtRepository(); - } - return PgDebtRepository.instance; - } - - async findAll(): Promise { - const result = await this.db - .select({ - id: debts.id, - user_id: debts.user_id, - description: debts.description, - original_amount: debts.original_amount, - pending_amount: debts.pending_amount, - due_date: debts.due_date, - paid: debts.paid, - creditor_id: debts.creditor_id, - category_id: debts.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - }) - .from(debts) - .leftJoin(categories, eq(debts.category_id, categories.id)); - return result.map(this.mapToEntity); - } - - async findById(id: number): Promise { - const result = await this.db - .select({ - id: debts.id, - user_id: debts.user_id, - description: debts.description, - original_amount: debts.original_amount, - pending_amount: debts.pending_amount, - due_date: debts.due_date, - paid: debts.paid, - creditor_id: debts.creditor_id, - category_id: debts.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - }) - .from(debts) - .leftJoin(categories, eq(debts.category_id, categories.id)) - .where(eq(debts.id, id)); - - return result[0] ? this.mapToEntity(result[0]) : null; - } - - async findByUserId(userId: number): Promise { - const result = await this.db - .select({ - id: debts.id, - user_id: debts.user_id, - description: debts.description, - original_amount: debts.original_amount, - pending_amount: debts.pending_amount, - due_date: debts.due_date, - paid: debts.paid, - creditor_id: debts.creditor_id, - category_id: debts.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - }) - .from(debts) - .leftJoin(categories, eq(debts.category_id, categories.id)) - .where(eq(debts.user_id, userId)); - - return result.map(this.mapToEntity); - } - - async findByCreditorId(creditorId: number): Promise { - const result = await this.db - .select({ - id: debts.id, - user_id: debts.user_id, - description: debts.description, - original_amount: debts.original_amount, - pending_amount: debts.pending_amount, - due_date: debts.due_date, - paid: debts.paid, - creditor_id: debts.creditor_id, - category_id: debts.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - }) - .from(debts) - .leftJoin(categories, eq(debts.category_id, categories.id)) - .where(eq(debts.creditor_id, creditorId)); - - return result.map(this.mapToEntity); - } - - async findByStatus(paid: boolean): Promise { - const result = await this.db - .select({ - id: debts.id, - user_id: debts.user_id, - description: debts.description, - original_amount: debts.original_amount, - pending_amount: debts.pending_amount, - due_date: debts.due_date, - paid: debts.paid, - creditor_id: debts.creditor_id, - category_id: debts.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - }) - .from(debts) - .leftJoin(categories, eq(debts.category_id, categories.id)) - .where(eq(debts.paid, paid)); - - return result.map(this.mapToEntity); - } - - async create(debtData: Omit): Promise { - - let debtCategoryId = debtData.categoryId || null; - - if (!debtCategoryId) { - const category = await this.db.select().from(categories).where(eq(categories.name, "Otros")).limit(1); - debtCategoryId = category[0].id; - } - - const result = await this.db - .insert(debts) - .values({ - user_id: debtData.userId, - description: debtData.description, - original_amount: debtData.originalAmount.toString(), - pending_amount: debtData.pendingAmount.toString(), - due_date: debtData.dueDate, - paid: debtData.paid, - creditor_id: debtData.creditorId || null, - category_id: debtCategoryId, - }) - .returning(); - - return this.mapToEntity(result[0]); - } - - async update(id: number, debtData: Partial): Promise { - const updateData: Record = {}; - - if (debtData.description !== undefined) - updateData.description = debtData.description; - if (debtData.pendingAmount !== undefined) - updateData.pending_amount = debtData.pendingAmount.toString(); - if (debtData.dueDate !== undefined) - updateData.due_date = debtData.dueDate; - if (debtData.paid !== undefined) updateData.paid = debtData.paid; - - const result = await this.db - .update(debts) - .set(updateData) - .where(eq(debts.id, id)) - .returning(); - - return this.mapToEntity(result[0]); - } - - async delete(id: number): Promise { - const result = await this.db - .delete(debts) - .where(eq(debts.id, id)) - .returning(); - - return result.length > 0; - } - - async updatePendingAmount(id: number, amount: number): Promise { - const debt = await this.findById(id); - if (!debt) throw new Error("Debt not found"); - - const newAmount = debt.pendingAmount - amount; - const paid = newAmount <= 0; - - const result = await this.db - .update(debts) - .set({ - pending_amount: newAmount.toString(), - paid, - }) - .where(eq(debts.id, id)) - .returning(); - - return this.mapToEntity(result[0]); - } - - private mapToEntity(raw: any): IDebt { - return { - id: raw.id, - userId: raw.user_id, - description: raw.description, - originalAmount: Number(raw.original_amount), - pendingAmount: Number(raw.pending_amount), - dueDate: raw.due_date, - paid: raw.paid, - creditorId: raw.creditor_id, - categoryId: raw.category_id, - category: raw.category ? { - id: raw.category.id, - name: raw.category.name, - description: raw.category.description, - } : null, - creditor: raw.creditor ? { - id: raw.creditor.id, - name: raw.creditor.name, - email: raw.creditor.email, - username: raw.creditor.username, - passwordHash: raw.creditor.password_hash, - registrationDate: raw.creditor.registration_date, - active: raw.creditor.active, - } : null, - createdAt: raw.created_at, - updatedAt: raw.updated_at, - }; - } + private db = DatabaseConnection.getInstance().db; + private static instance: PgDebtRepository; + + private constructor() {} + + public static getInstance(): PgDebtRepository { + if (!PgDebtRepository.instance) { + PgDebtRepository.instance = new PgDebtRepository(); + } + return PgDebtRepository.instance; + } + + async findAll(): Promise { + const result = await this.db + .select({ + id: debts.id, + user_id: debts.user_id, + description: debts.description, + original_amount: debts.original_amount, + pending_amount: debts.pending_amount, + due_date: debts.due_date, + paid: debts.paid, + creditor_id: debts.creditor_id, + category_id: debts.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + }) + .from(debts) + .leftJoin(categories, eq(debts.category_id, categories.id)); + return result.map(this.mapToEntity); + } + + async findById(id: number): Promise { + const result = await this.db + .select({ + id: debts.id, + user_id: debts.user_id, + description: debts.description, + original_amount: debts.original_amount, + pending_amount: debts.pending_amount, + due_date: debts.due_date, + paid: debts.paid, + creditor_id: debts.creditor_id, + category_id: debts.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + }) + .from(debts) + .leftJoin(categories, eq(debts.category_id, categories.id)) + .where(eq(debts.id, id)); + + return result[0] ? this.mapToEntity(result[0]) : null; + } + + async findByUserId(userId: number): Promise { + const result = await this.db + .select({ + id: debts.id, + user_id: debts.user_id, + description: debts.description, + original_amount: debts.original_amount, + pending_amount: debts.pending_amount, + due_date: debts.due_date, + paid: debts.paid, + creditor_id: debts.creditor_id, + category_id: debts.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + }) + .from(debts) + .leftJoin(categories, eq(debts.category_id, categories.id)) + .where(eq(debts.user_id, userId)); + + return result.map(this.mapToEntity); + } + + async findByCreditorId(creditorId: number): Promise { + const result = await this.db + .select({ + id: debts.id, + user_id: debts.user_id, + description: debts.description, + original_amount: debts.original_amount, + pending_amount: debts.pending_amount, + due_date: debts.due_date, + paid: debts.paid, + creditor_id: debts.creditor_id, + category_id: debts.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + }) + .from(debts) + .leftJoin(categories, eq(debts.category_id, categories.id)) + .where(eq(debts.creditor_id, creditorId)); + + return result.map(this.mapToEntity); + } + + async findByStatus(paid: boolean): Promise { + const result = await this.db + .select({ + id: debts.id, + user_id: debts.user_id, + description: debts.description, + original_amount: debts.original_amount, + pending_amount: debts.pending_amount, + due_date: debts.due_date, + paid: debts.paid, + creditor_id: debts.creditor_id, + category_id: debts.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + }) + .from(debts) + .leftJoin(categories, eq(debts.category_id, categories.id)) + .where(eq(debts.paid, paid)); + + return result.map(this.mapToEntity); + } + + async create(debtData: Omit): Promise { + let debtCategoryId = debtData.categoryId || null; + + if (!debtCategoryId) { + const category = await this.db + .select() + .from(categories) + .where(eq(categories.name, "Otros")) + .limit(1); + debtCategoryId = category[0].id; + } + + const result = await this.db + .insert(debts) + .values({ + user_id: debtData.userId, + description: debtData.description, + original_amount: debtData.originalAmount.toString(), + pending_amount: debtData.pendingAmount.toString(), + due_date: debtData.dueDate, + paid: debtData.paid, + creditor_id: debtData.creditorId || null, + category_id: debtCategoryId, + }) + .returning(); + + return this.mapToEntity(result[0]); + } + + async update(id: number, debtData: Partial): Promise { + const updateData: Record = {}; + + if (debtData.description !== undefined) + updateData.description = debtData.description; + if (debtData.pendingAmount !== undefined) + updateData.pending_amount = debtData.pendingAmount.toString(); + if (debtData.dueDate !== undefined) updateData.due_date = debtData.dueDate; + if (debtData.paid !== undefined) updateData.paid = debtData.paid; + + const result = await this.db + .update(debts) + .set(updateData) + .where(eq(debts.id, id)) + .returning(); + + return this.mapToEntity(result[0]); + } + + async delete(id: number): Promise { + const result = await this.db + .delete(debts) + .where(eq(debts.id, id)) + .returning(); + + return result.length > 0; + } + + async updatePendingAmount(id: number, amount: number): Promise { + const debt = await this.findById(id); + if (!debt) throw new Error("Debt not found"); + + const newAmount = debt.pendingAmount - amount; + const paid = newAmount <= 0; + + const result = await this.db + .update(debts) + .set({ + pending_amount: newAmount.toString(), + paid, + }) + .where(eq(debts.id, id)) + .returning(); + + return this.mapToEntity(result[0]); + } + + private mapToEntity(raw: any): IDebt { + return { + id: raw.id, + userId: raw.user_id, + description: raw.description, + originalAmount: Number(raw.original_amount), + pendingAmount: Number(raw.pending_amount), + dueDate: raw.due_date, + paid: raw.paid, + creditorId: raw.creditor_id, + categoryId: raw.category_id, + category: raw.category + ? { + id: raw.category.id, + name: raw.category.name, + description: raw.category.description, + } + : null, + creditor: raw.creditor + ? { + id: raw.creditor.id, + name: raw.creditor.name, + email: raw.creditor.email, + username: raw.creditor.username, + passwordHash: raw.creditor.password_hash, + registration_date: raw.creditor.registration_date, + active: raw.creditor.active, + } + : null, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + }; + } } diff --git a/src/features/debts/tests/debt.service.test.ts b/src/features/debts/tests/debt.service.test.ts new file mode 100644 index 0000000..6750f8d --- /dev/null +++ b/src/features/debts/tests/debt.service.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DebtService } from '../application/services/debt.service'; +import { IDebtRepository } from '../domain/ports/debt-repository.port'; +import { DebtUtilsService } from '../application/services/debt-utils.service'; +import { ITransactionRepository } from '@/transactions/domain/ports/transaction-repository.port'; +import { IPaymentMethodRepository } from '@/payment-methods/domain/ports/payment-method-repository.port'; +import * as HttpStatusCodes from 'stoker/http-status-codes'; +import { DebtApiAdapter } from '../infrastructure/adapters/debt-api.adapter'; +import { TransactionApiAdapter } from '@/transactions/infrastructure/adapters/transaction-api.adapter'; + +// Mocks +vi.mock('../infrastructure/adapters/debt-api.adapter', () => ({ + DebtApiAdapter: { + toApiResponseList: vi.fn((data) => data), + toApiResponse: vi.fn((data) => data), + }, +})); + +vi.mock('@/transactions/infrastructure/adapters/transaction-api.adapter', () => ({ + TransactionApiAdapter: { + toApiResponseList: vi.fn((data) => data), + toApiResponse: vi.fn((data) => data), + }, +})); + +vi.mock('../application/services/debt-notification.service', () => ({ + DebtNotificationService: { + getInstance: vi.fn().mockReturnValue({ + notifyDebtCreated: vi.fn(), + checkDueDateApproaching: vi.fn(), + notifyDebtPaymentStatusChanged: vi.fn(), + notifyDebtAmountUpdated: vi.fn(), + }), + }, +})); + +describe('DebtService', () => { + let debtService: DebtService; + let debtRepositoryMock: IDebtRepository; + let transactionRepositoryMock: ITransactionRepository; + let debtUtilsMock: DebtUtilsService; + let paymentMethodRepositoryMock: IPaymentMethodRepository; + + beforeEach(() => { + debtRepositoryMock = { + findAll: vi.fn(), + findById: vi.fn(), + findByUserId: vi.fn(), + findByCreditorId: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + updatePendingAmount: vi.fn(), + } as any; + + transactionRepositoryMock = { + create: vi.fn(), + findByFilters: vi.fn(), + } as any; + + debtUtilsMock = { + validateUser: vi.fn(), + validateDebt: vi.fn(), + validatePaymentMethod: vi.fn(), + } as any; + + paymentMethodRepositoryMock = { + findById: vi.fn(), + } as any; + + (DebtService as any).instance = null; + debtService = DebtService.getInstance( + debtRepositoryMock, + transactionRepositoryMock, + debtUtilsMock, + paymentMethodRepositoryMock + ); + }); + + describe('getAll', () => { + it('should return all debts', async () => { + (debtRepositoryMock.findAll as any).mockResolvedValue([]); + const c = { + json: vi.fn(), + }; + + await debtService.getAll(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Debts retrieved successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('getById', () => { + it('should return 404 if debt not found', async () => { + (debtRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await debtService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Debt not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return debt if found', async () => { + (debtRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await debtService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Debt retrieved successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('getByUserId', () => { + it('should return 404 if user not found', async () => { + (debtUtilsMock.validateUser as any).mockResolvedValue({ isValid: false }); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await debtService.getByUserId(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return user debts if user found', async () => { + (debtUtilsMock.validateUser as any).mockResolvedValue({ isValid: true }); + (debtRepositoryMock.findByUserId as any).mockResolvedValue([]); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await debtService.getByUserId(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'User debts retrieved successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('create', () => { + it('should return 404 if user not found', async () => { + (debtUtilsMock.validateUser as any).mockResolvedValue({ isValid: false }); + const c = { + req: { valid: vi.fn().mockReturnValue({ user_id: 1 }) }, + json: vi.fn(), + }; + + await debtService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should create debt successfully', async () => { + (debtUtilsMock.validateUser as any).mockResolvedValue({ isValid: true }); + (debtRepositoryMock.create as any).mockResolvedValue({ id: 1 }); + const c = { + req: { valid: vi.fn().mockReturnValue({ user_id: 1, original_amount: 100, due_date: '2023-01-01' }) }, + json: vi.fn(), + }; + + await debtService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Debt created successfully' }), + HttpStatusCodes.CREATED + ); + }); + }); + + describe('update', () => { + it('should return 404 if debt not found', async () => { + (debtRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1'), valid: vi.fn().mockReturnValue({}) }, + json: vi.fn(), + }; + + await debtService.update(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Debt not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should update debt successfully', async () => { + (debtRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + (debtRepositoryMock.update as any).mockResolvedValue({ id: 1 }); + const c = { + req: { param: vi.fn().mockReturnValue('1'), valid: vi.fn().mockReturnValue({ pending_amount: 50 }) }, + json: vi.fn(), + }; + + await debtService.update(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Debt updated successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('delete', () => { + it('should return 404 if debt not found', async () => { + (debtRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await debtService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Debt not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should delete debt successfully', async () => { + (debtRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + (debtRepositoryMock.delete as any).mockResolvedValue(true); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await debtService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Debt deleted successfully' }), + HttpStatusCodes.OK + ); + }); + }); +}); diff --git a/src/features/email/tests/email.service.test.ts b/src/features/email/tests/email.service.test.ts new file mode 100644 index 0000000..3b035a5 --- /dev/null +++ b/src/features/email/tests/email.service.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EmailService } from '../application/services/email.service'; +import { EmailServiceFactory } from '../application/services/email-service.factory'; +import { IEmailService } from '../domain/ports/email-service.port'; + +// Mocks +vi.mock('../application/services/email-service.factory', () => ({ + EmailServiceFactory: { + getInstance: vi.fn().mockReturnValue({ + getEmailService: vi.fn(), + }), + }, +})); + +describe('EmailService', () => { + let emailService: EmailService; + let emailServiceMock: IEmailService; + + beforeEach(() => { + emailServiceMock = { + sendEmail: vi.fn(), + } as any; + + (EmailServiceFactory.getInstance().getEmailService as any).mockReturnValue(emailServiceMock); + + (EmailService as any).instance = null; + emailService = EmailService.getInstance(); + }); + + describe('sendSimpleEmail', () => { + it('should send simple email successfully', async () => { + (emailServiceMock.sendEmail as any).mockResolvedValue(true); + + const result = await emailService.sendSimpleEmail( + 'test@example.com', + 'Subject', + 'Content' + ); + + expect(result).toBe(true); + expect(emailServiceMock.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'test@example.com', + subject: 'Subject', + text: 'Content', + }) + ); + }); + + it('should send simple html email successfully', async () => { + (emailServiceMock.sendEmail as any).mockResolvedValue(true); + + const result = await emailService.sendSimpleEmail( + 'test@example.com', + 'Subject', + '

Content

', + { isHtml: true } + ); + + expect(result).toBe(true); + expect(emailServiceMock.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'test@example.com', + subject: 'Subject', + html: '

Content

', + }) + ); + }); + }); + + describe('sendEmailWithAttachments', () => { + it('should send email with attachments successfully', async () => { + (emailServiceMock.sendEmail as any).mockResolvedValue(true); + + const attachments = [{ filename: 'test.txt', content: 'test' }]; + const result = await emailService.sendEmailWithAttachments( + 'test@example.com', + 'Subject', + 'Content', + attachments + ); + + expect(result).toBe(true); + expect(emailServiceMock.sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'test@example.com', + subject: 'Subject', + text: 'Content', + attachments, + }) + ); + }); + }); + + describe('sendCustomEmail', () => { + it('should send custom email successfully', async () => { + (emailServiceMock.sendEmail as any).mockResolvedValue(true); + + const email = { + to: 'test@example.com', + subject: 'Subject', + text: 'Content', + }; + const result = await emailService.sendCustomEmail(email); + + expect(result).toBe(true); + expect(emailServiceMock.sendEmail).toHaveBeenCalledWith(email); + }); + }); +}); diff --git a/src/features/friends/tests/friend.service.test.ts b/src/features/friends/tests/friend.service.test.ts new file mode 100644 index 0000000..e35202f --- /dev/null +++ b/src/features/friends/tests/friend.service.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { FriendService } from '../application/services/friend.service'; +import { IFriendRepository } from '../domain/ports/friend-repository.port'; +import { FriendUtilsService } from '../application/services/friend-utils.service'; +import { IUserRepository } from '@/users/domain/ports/user-repository.port'; +import * as HttpStatusCodes from 'stoker/http-status-codes'; +import { FriendApiAdapter } from '../infrastructure/adapters/friend-api.adapter'; + +// Mocks +vi.mock('../infrastructure/adapters/friend-api.adapter', () => ({ + FriendApiAdapter: { + toApiResponseList: vi.fn((data) => data), + toApiResponse: vi.fn((data) => data), + }, +})); + +describe('FriendService', () => { + let friendService: FriendService; + let friendRepositoryMock: IFriendRepository; + let friendUtilsMock: FriendUtilsService; + let userRepositoryMock: IUserRepository; + + beforeEach(() => { + friendRepositoryMock = { + findAll: vi.fn(), + findById: vi.fn(), + findByUserId: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + } as any; + + friendUtilsMock = { + validateUser: vi.fn(), + validateFriendship: vi.fn(), + } as any; + + userRepositoryMock = { + findByEmail: vi.fn(), + } as any; + + (FriendService as any).instance = null; + friendService = FriendService.getInstance(friendRepositoryMock, friendUtilsMock, userRepositoryMock); + }); + + describe('getAll', () => { + it('should return all friends', async () => { + (friendRepositoryMock.findAll as any).mockResolvedValue([]); + const c = { + json: vi.fn(), + }; + + await friendService.getAll(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Friends retrieved successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('getById', () => { + it('should return 404 if friend not found', async () => { + (friendRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await friendService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Friend not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return friend if found', async () => { + (friendRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await friendService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Friend retrieved successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('getByUserId', () => { + it('should return 404 if user not found', async () => { + (friendUtilsMock.validateUser as any).mockResolvedValue({ isValid: false }); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await friendService.getByUserId(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return user friends if user found', async () => { + (friendUtilsMock.validateUser as any).mockResolvedValue({ isValid: true }); + (friendRepositoryMock.findByUserId as any).mockResolvedValue([]); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await friendService.getByUserId(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'User friends retrieved successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('create', () => { + it('should return 404 if friend email not found', async () => { + (userRepositoryMock.findByEmail as any).mockResolvedValue(null); + const c = { + req: { valid: vi.fn().mockReturnValue({ user_id: 1, friend_email: 'test@example.com' }) }, + json: vi.fn(), + }; + + await friendService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return 400 if friendship is invalid', async () => { + (userRepositoryMock.findByEmail as any).mockResolvedValue({ id: 2 }); + (friendUtilsMock.validateFriendship as any).mockResolvedValue({ isValid: false, message: 'Invalid' }); + const c = { + req: { valid: vi.fn().mockReturnValue({ user_id: 1, friend_email: 'test@example.com' }) }, + json: vi.fn(), + }; + + await friendService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Invalid' }), + HttpStatusCodes.BAD_REQUEST + ); + }); + + it('should create friendship if valid', async () => { + (userRepositoryMock.findByEmail as any).mockResolvedValue({ id: 2 }); + (friendUtilsMock.validateFriendship as any).mockResolvedValue({ isValid: true }); + (friendRepositoryMock.create as any).mockResolvedValue({ id: 1 }); + const c = { + req: { valid: vi.fn().mockReturnValue({ user_id: 1, friend_email: 'test@example.com' }) }, + json: vi.fn(), + }; + + await friendService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Friend added successfully' }), + HttpStatusCodes.CREATED + ); + }); + }); + + describe('delete', () => { + it('should return 404 if friend not found', async () => { + (friendRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await friendService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Friend not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should delete friend if found', async () => { + (friendRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + (friendRepositoryMock.delete as any).mockResolvedValue(true); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await friendService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Friend removed successfully' }), + HttpStatusCodes.OK + ); + }); + }); +}); diff --git a/src/features/goals/domain/ports/goal-repository.port.ts b/src/features/goals/domain/ports/goal-repository.port.ts index f6daf49..8d3685f 100644 --- a/src/features/goals/domain/ports/goal-repository.port.ts +++ b/src/features/goals/domain/ports/goal-repository.port.ts @@ -2,6 +2,7 @@ import { IGoal } from "../entities/IGoal"; export interface IGoalRepository { findAll(): Promise; + findAllActive(): Promise; findById(id: number): Promise; findByUserId(userId: number): Promise; findSharedWithUser(userId: number): Promise; diff --git a/src/features/goals/infrastucture/adapters/goal.repository.ts b/src/features/goals/infrastucture/adapters/goal.repository.ts index 80813f6..93b0151 100644 --- a/src/features/goals/infrastucture/adapters/goal.repository.ts +++ b/src/features/goals/infrastucture/adapters/goal.repository.ts @@ -1,4 +1,4 @@ -import { eq, sql, and, gte, lte } from "drizzle-orm"; +import { eq, sql, and, gte, lte, or } from "drizzle-orm"; import DatabaseConnection from "@/core/infrastructure/database"; import { categories, goal_contributions, goals } from "@/schema"; import { IGoalRepository } from "@/goals/domain/ports/goal-repository.port"; @@ -30,6 +30,25 @@ export class PgGoalRepository implements IGoalRepository { return result.map((row) => this.mapToEntity(row.goal, row.category)); } + async findAllActive(): Promise { + const now = new Date(); + const result = await this.db + .select({ + goal: goals, + category: categories, + }) + .from(goals) + .leftJoin(categories, eq(goals.category_id, categories.id)) + .where( + and( + gte(goals.end_date, now), + sql`CAST(${goals.current_amount} AS DECIMAL) < CAST(${goals.target_amount} AS DECIMAL)` + ) + ); + + return result.map((row) => this.mapToEntity(row.goal, row.category)); + } + async findAllWithLastContributionWithMoreThanOneWeekAgo(): Promise { const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); @@ -48,21 +67,6 @@ export class PgGoalRepository implements IGoalRepository { return result.map((row) => this.mapToEntity(row.goal)); } - async findById(id: number): Promise { - const result = await this.db - .select({ - goal: goals, - category: categories, - }) - .from(goals) - .leftJoin(categories, eq(goals.category_id, categories.id)) - .where(eq(goals.id, id)); - - return result[0] - ? this.mapToEntity(result[0].goal, result[0].category) - : null; - } - async findByUserId(userId: number): Promise { const result = await this.db .select({ @@ -71,22 +75,7 @@ export class PgGoalRepository implements IGoalRepository { }) .from(goals) .leftJoin(categories, eq(goals.category_id, categories.id)) - .where(eq(goals.user_id, userId)); - - return result.map((row) => this.mapToEntity(row.goal, row.category)); - } - - async findAllActive(): Promise { - const result = await this.db - .select({ - goal: goals, - category: categories, - }) - .from(goals) - .leftJoin(categories, eq(goals.category_id, categories.id)) - .where( - sql`${goals.current_amount} < ${goals.target_amount}` - ); + .where(or(eq(goals.user_id, userId), eq(goals.shared_user_id, userId))); return result.map((row) => this.mapToEntity(row.goal, row.category)); } @@ -104,54 +93,46 @@ export class PgGoalRepository implements IGoalRepository { return result.map((row) => this.mapToEntity(row.goal, row.category)); } - async create(goalData: Omit): Promise { + async create(goal: IGoal): Promise { const result = await this.db .insert(goals) .values({ - user_id: goalData.userId, - shared_user_id: goalData.sharedUserId, - name: goalData.name, - target_amount: goalData.targetAmount.toString(), - current_amount: goalData.currentAmount.toString(), - end_date: goalData.endDate, - category_id: goalData.categoryId, - contribution_frequency: goalData.contributionFrequency || 0, - contribution_amount: goalData.contributionAmount?.toString() || "0" + user_id: goal.userId, + shared_user_id: goal.sharedUserId, + name: goal.name, + target_amount: goal.targetAmount.toString(), + current_amount: goal.currentAmount.toString(), + end_date: goal.endDate, + category_id: goal.categoryId, + contribution_frequency: goal.contributionFrequency || 0, + contribution_amount: goal.contributionAmount?.toString() || "0", }) .returning(); - const category = goalData.categoryId + const category = result[0].category_id ? await this.db .select() .from(categories) - .where(eq(categories.id, goalData.categoryId)) + .where(eq(categories.id, result[0].category_id)) .then((result) => result[0]) : null; return this.mapToEntity(result[0], category); } - async update(id: number, goalData: Partial): Promise { - const updateData: Record = {}; - - if (goalData.name !== undefined) updateData.name = goalData.name; - if (goalData.targetAmount !== undefined) - updateData.target_amount = goalData.targetAmount.toString(); - if (goalData.currentAmount !== undefined) - updateData.current_amount = goalData.currentAmount.toString(); - if (goalData.endDate !== undefined) updateData.end_date = goalData.endDate; - if (goalData.sharedUserId !== undefined) - updateData.shared_user_id = goalData.sharedUserId; - if (goalData.categoryId !== undefined) - updateData.category_id = goalData.categoryId; - if (goalData.contributionFrequency !== undefined) - updateData.contribution_frequency = goalData.contributionFrequency; - if (goalData.contributionAmount !== undefined) - updateData.contribution_amount = goalData.contributionAmount?.toString() || null; - + async update(id: number, goal: Partial): Promise { const result = await this.db .update(goals) - .set(updateData) + .set({ + name: goal.name, + target_amount: goal.targetAmount?.toString(), + current_amount: goal.currentAmount?.toString(), + end_date: goal.endDate, + category_id: goal.categoryId, + shared_user_id: goal.sharedUserId, + contribution_frequency: goal.contributionFrequency ?? undefined, + contribution_amount: goal.contributionAmount?.toString(), + }) .where(eq(goals.id, id)) .returning(); @@ -166,6 +147,21 @@ export class PgGoalRepository implements IGoalRepository { return this.mapToEntity(result[0], category); } + async findById(id: number): Promise { + const result = await this.db + .select({ + goal: goals, + category: categories, + }) + .from(goals) + .leftJoin(categories, eq(goals.category_id, categories.id)) + .where(eq(goals.id, id)); + + if (result.length === 0) return null; + + return this.mapToEntity(result[0].goal, result[0].category); + } + async delete(id: number): Promise { const result = await this.db .delete(goals) @@ -203,22 +199,38 @@ export class PgGoalRepository implements IGoalRepository { async findByFilters(filters: ReportFilters): Promise { const conditions = []; + // Usuario es obligatorio if (filters.userId) { - conditions.push(eq(goals.user_id, Number(filters.userId))); + conditions.push( + or( + eq(goals.user_id, Number(filters.userId)), + eq(goals.shared_user_id, Number(filters.userId)) + ) + ); } + // Filtro por categoría (opcional) if (filters.categoryId) { conditions.push(eq(goals.category_id, Number(filters.categoryId))); } + // FIXED: Determinar qué campo usar para filtrar fechas + // Default: 'end_date' (para dashboard - metas que vencen en el período) + // Opción: 'created_at' (para reportes - metas creadas en el período) + const useCreatedAt = filters.filterBy === "created_at"; + const dateField = useCreatedAt ? goals.created_at : goals.end_date; + + // Si hay startDate, filtrar metas según el campo determinado if (filters.startDate) { - conditions.push(gte(goals.end_date, new Date(filters.startDate))); + conditions.push(gte(dateField, filters.startDate)); } + // Si hay endDate, filtrar metas según el campo determinado if (filters.endDate) { - conditions.push(lte(goals.end_date, new Date(filters.endDate))); + conditions.push(lte(dateField, filters.endDate)); } + // Incluir compartidas (opcional) if (filters.includeShared) { conditions.push(sql`${goals.shared_user_id} IS NOT NULL`); } @@ -252,9 +264,11 @@ export class PgGoalRepository implements IGoalRepository { } : null, contributionFrequency: raw.contribution_frequency, - contributionAmount: raw.contribution_amount ? Number(raw.contribution_amount) : 0, + contributionAmount: raw.contribution_amount + ? Number(raw.contribution_amount) + : 0, createdAt: raw.created_at, - updatedAt: raw.updated_at + updatedAt: raw.updated_at, }; } } diff --git a/src/features/goals/tests/goal.service.test.ts b/src/features/goals/tests/goal.service.test.ts new file mode 100644 index 0000000..f3661c5 --- /dev/null +++ b/src/features/goals/tests/goal.service.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GoalService } from '../application/services/goal.service'; +import { IGoalRepository } from '@/goals/domain/ports/goal-repository.port'; +import { GoalUtilsService } from '../application/services/goal-utils.service'; +import { IGoalContributionRepository } from '@/goals/domain/ports/goal-contribution-repository.port'; +import { ITransactionRepository } from '@/transactions/domain/ports/transaction-repository.port'; +import { GoalNotificationService } from '../application/services/goal-notification.service'; +import { GoalScheduleGeneratorService } from '../application/services/goal-schedule-generator.service'; +import { PgGoalContributionScheduleRepository } from '@/goals/infrastucture/adapters/goal-contribution-schedule.repository'; +import * as HttpStatusCodes from 'stoker/http-status-codes'; +import { GoalApiAdapter } from '@/goals/infrastucture/adapters/goal-api.adapter'; + +vi.mock('@/goals/infrastucture/adapters/goal-api.adapter', () => ({ + GoalApiAdapter: { + toApiResponseList: vi.fn((data) => data), + toApiResponse: vi.fn((data) => data), + }, +})); + +// Mocks +vi.mock('../application/services/goal-notification.service', () => ({ + GoalNotificationService: { + getInstance: vi.fn(), + }, +})); + +vi.mock('../application/services/goal-schedule-generator.service', () => ({ + GoalScheduleGeneratorService: { + getInstance: vi.fn(), + }, +})); + +vi.mock('@/goals/infrastucture/adapters/goal-contribution-schedule.repository', () => ({ + PgGoalContributionScheduleRepository: { + getInstance: vi.fn(), + }, +})); + +describe('GoalService', () => { + let goalService: GoalService; + let goalRepositoryMock: IGoalRepository; + let goalUtilsMock: GoalUtilsService; + let goalContributionRepositoryMock: IGoalContributionRepository; + let transactionRepositoryMock: ITransactionRepository; + let goalNotificationServiceMock: any; + let goalScheduleGeneratorServiceMock: any; + + beforeEach(() => { + goalRepositoryMock = { + findAll: vi.fn(), + findById: vi.fn(), + findByUserId: vi.fn(), + findSharedWithUser: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + updateProgress: vi.fn(), + findByFilters: vi.fn(), + } as any; + + goalUtilsMock = { + validateUser: vi.fn(), + validateSharing: vi.fn(), + validateProgress: vi.fn(), + } as any; + + goalContributionRepositoryMock = { + findByGoalId: vi.fn(), + } as any; + + transactionRepositoryMock = { + findByContributionId: vi.fn(), + } as any; + + goalNotificationServiceMock = { + notifyGoalShared: vi.fn(), + checkDeadlineApproaching: vi.fn(), + checkProgressMilestones: vi.fn(), + }; + (GoalNotificationService.getInstance as any) = vi.fn().mockReturnValue(goalNotificationServiceMock); + + goalScheduleGeneratorServiceMock = { + generateSchedules: vi.fn(), + recalculateSchedules: vi.fn(), + }; + (GoalScheduleGeneratorService.getInstance as any) = vi.fn().mockReturnValue(goalScheduleGeneratorServiceMock); + + (PgGoalContributionScheduleRepository.getInstance as any) = vi.fn().mockReturnValue({}); + + (GoalService as any).instance = null; + goalService = GoalService.getInstance( + goalRepositoryMock, + goalUtilsMock, + goalContributionRepositoryMock, + transactionRepositoryMock + ); + }); + + describe('getAll', () => { + it('should return all goals', async () => { + const goals = [{ id: 1, name: 'Goal 1' }]; + (goalRepositoryMock.findAll as any).mockResolvedValue(goals); + const c = { + json: vi.fn(), + }; + + await goalService.getAll(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.arrayContaining([expect.objectContaining({ name: 'Goal 1' })]), + }), + HttpStatusCodes.OK + ); + }); + }); + + describe('getById', () => { + it('should return 404 if goal not found', async () => { + (goalRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await goalService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Goal not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return goal if found', async () => { + const goal = { id: 1, name: 'Goal 1' }; + (goalRepositoryMock.findById as any).mockResolvedValue(goal); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await goalService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ name: 'Goal 1' }), + }), + HttpStatusCodes.OK + ); + }); + }); + + describe('create', () => { + it('should return 404 if user not found', async () => { + (goalUtilsMock.validateUser as any).mockResolvedValue({ isValid: false }); + const c = { + req: { valid: vi.fn().mockReturnValue({ user_id: 1 }) }, + json: vi.fn(), + }; + + await goalService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should create goal successfully', async () => { + (goalUtilsMock.validateUser as any).mockResolvedValue({ isValid: true }); + const goalData = { + user_id: 1, + name: 'New Goal', + target_amount: 1000, + end_date: '2023-12-31', + }; + const createdGoal = { id: 1, ...goalData, endDate: new Date(goalData.end_date) }; + (goalRepositoryMock.create as any).mockResolvedValue(createdGoal); + const c = { + req: { valid: vi.fn().mockReturnValue(goalData) }, + json: vi.fn(), + }; + + await goalService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ name: 'New Goal' }), + }), + HttpStatusCodes.CREATED + ); + expect(goalScheduleGeneratorServiceMock.generateSchedules).toHaveBeenCalledWith(createdGoal); + }); + }); + + describe('update', () => { + it('should return 404 if goal not found', async () => { + (goalRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1'), valid: vi.fn().mockReturnValue({}) }, + json: vi.fn(), + }; + + await goalService.update(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Goal not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should update goal successfully', async () => { + const existingGoal = { id: 1, name: 'Old Name', userId: 1 }; + (goalRepositoryMock.findById as any).mockResolvedValue(existingGoal); + const updateData = { name: 'New Name' }; + const updatedGoal = { ...existingGoal, ...updateData }; + (goalRepositoryMock.update as any).mockResolvedValue(updatedGoal); + + const c = { + req: { param: vi.fn().mockReturnValue('1'), valid: vi.fn().mockReturnValue(updateData) }, + json: vi.fn(), + }; + + await goalService.update(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ name: 'New Name' }), + }), + HttpStatusCodes.OK + ); + }); + }); + + describe('delete', () => { + it('should return 404 if goal not found', async () => { + (goalRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await goalService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Goal not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should delete goal successfully', async () => { + (goalRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + (goalRepositoryMock.delete as any).mockResolvedValue(true); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await goalService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: { deleted: true }, + }), + HttpStatusCodes.OK + ); + }); + }); +}); diff --git a/src/features/notifications/infrastructure/adapters/notification.repository.ts b/src/features/notifications/infrastructure/adapters/notification.repository.ts index e949c6f..ce2c912 100644 --- a/src/features/notifications/infrastructure/adapters/notification.repository.ts +++ b/src/features/notifications/infrastructure/adapters/notification.repository.ts @@ -1,7 +1,10 @@ import { eq, and, lt } from "drizzle-orm"; import DatabaseConnection from "@/core/infrastructure/database"; import { notifications } from "@/schema"; -import { INotification, NotificationType } from "../../domain/entities/INotification"; +import { + INotification, + NotificationType, +} from "../../domain/entities/INotification"; import { INotificationRepository } from "../../domain/ports/notification-repository.port"; export class PgNotificationRepository implements INotificationRepository { @@ -45,16 +48,18 @@ export class PgNotificationRepository implements INotificationRepository { .select() .from(notifications) .where( - and( - eq(notifications.user_id, userId), - eq(notifications.read, false) - ) + and(eq(notifications.user_id, userId), eq(notifications.read, false)) ); return result.map(this.mapToEntity); } - async create(notificationData: Omit): Promise { + async create( + notificationData: Omit< + INotification, + "id" | "createdAt" | "updatedAt" | "read" + > + ): Promise { const result = await this.db .insert(notifications) .values({ @@ -62,7 +67,7 @@ export class PgNotificationRepository implements INotificationRepository { title: notificationData.title, subtitle: notificationData.subtitle || null, message: notificationData.message, - read: notificationData.read, + read: false, type: notificationData.type, expires_at: notificationData.expiresAt || null, }) @@ -71,15 +76,24 @@ export class PgNotificationRepository implements INotificationRepository { return this.mapToEntity(result[0]); } - async update(id: number, notificationData: Partial): Promise { + async update( + id: number, + notificationData: Partial + ): Promise { const updateData: Record = {}; - if (notificationData.title !== undefined) updateData.title = notificationData.title; - if (notificationData.subtitle !== undefined) updateData.subtitle = notificationData.subtitle; - if (notificationData.message !== undefined) updateData.message = notificationData.message; - if (notificationData.read !== undefined) updateData.read = notificationData.read; - if (notificationData.type !== undefined) updateData.type = notificationData.type; - if (notificationData.expiresAt !== undefined) updateData.expires_at = notificationData.expiresAt; + if (notificationData.title !== undefined) + updateData.title = notificationData.title; + if (notificationData.subtitle !== undefined) + updateData.subtitle = notificationData.subtitle; + if (notificationData.message !== undefined) + updateData.message = notificationData.message; + if (notificationData.read !== undefined) + updateData.read = notificationData.read; + if (notificationData.type !== undefined) + updateData.type = notificationData.type; + if (notificationData.expiresAt !== undefined) + updateData.expires_at = notificationData.expiresAt; const result = await this.db .update(notifications) @@ -114,10 +128,7 @@ export class PgNotificationRepository implements INotificationRepository { .update(notifications) .set({ read: true }) .where( - and( - eq(notifications.user_id, userId), - eq(notifications.read, false) - ) + and(eq(notifications.user_id, userId), eq(notifications.read, false)) ) .returning(); @@ -128,16 +139,12 @@ export class PgNotificationRepository implements INotificationRepository { const now = new Date(); const result = await this.db .delete(notifications) - .where( - and( - lt(notifications.expires_at, now) - ) - ) + .where(and(lt(notifications.expires_at, now))) .returning(); return result.length; } - + async findByUserIdAndType( userId: number, type: NotificationType, @@ -147,18 +154,12 @@ export class PgNotificationRepository implements INotificationRepository { eq(notifications.user_id, userId), eq(notifications.type, type) ); - + if (afterDate) { - conditions = and( - conditions, - lt(notifications.created_at, afterDate) - ); + conditions = and(conditions, lt(notifications.created_at, afterDate)); } - - const result = await this.db - .select() - .from(notifications) - .where(conditions); + + const result = await this.db.select().from(notifications).where(conditions); return result.map(this.mapToEntity); } diff --git a/src/features/notifications/infrastructure/websocket/notification-socket.router.ts b/src/features/notifications/infrastructure/websocket/notification-socket.router.ts index 4a8940f..8350718 100644 --- a/src/features/notifications/infrastructure/websocket/notification-socket.router.ts +++ b/src/features/notifications/infrastructure/websocket/notification-socket.router.ts @@ -8,7 +8,8 @@ const router = createRouter(); const notificationSocketService = NotificationSocketService.getInstance(); // WebSocket endpoint for notifications -router.get('/notifications/ws', notificationSocketService.createWebSocketMiddleware()); +const { upgrade } = notificationSocketService.createWebSocketMiddleware(); +router.get('/notifications/ws', upgrade); // Endpoint to get WebSocket connection statistics (for monitoring/debugging) router.openapi(openapi.wsStats, async (c) => { diff --git a/src/features/notifications/infrastructure/websocket/notification-socket.service.ts b/src/features/notifications/infrastructure/websocket/notification-socket.service.ts index 7b6a28c..714d663 100644 --- a/src/features/notifications/infrastructure/websocket/notification-socket.service.ts +++ b/src/features/notifications/infrastructure/websocket/notification-socket.service.ts @@ -1,8 +1,19 @@ import { Hono } from "hono"; -import { createBunWebSocket } from "hono/bun"; import { verifyToken } from "@/shared/utils/jwt.util"; import { INotification } from "../../domain/entities/INotification"; import { NotificationApiAdapter } from "../adapters/notification-api.adapter"; +type CreateBunWebSocket = typeof import("hono/bun").createBunWebSocket; + +const isBunRuntime = + typeof (globalThis as any).Bun !== "undefined" && + typeof (globalThis as any).Bun?.serve === "function"; + +let createBunWebSocketFn: CreateBunWebSocket | null = null; + +if (isBunRuntime) { + const bunModule = await import("hono/bun"); + createBunWebSocketFn = bunModule.createBunWebSocket; +} /** * Service for managing WebSocket connections for real-time notifications @@ -24,8 +35,24 @@ export class NotificationSocketService { /** * Create WebSocket middleware for Hono with Bun */ - public createWebSocketMiddleware(): any { - const { upgradeWebSocket, websocket } = createBunWebSocket(); + public createWebSocketMiddleware(): { + upgrade: any; + websocket?: any; + } { + if (!createBunWebSocketFn) { + const fallback = (c: any) => + c.json( + { + success: false, + message: + "WebSocket no disponible en este entorno (requiere runtime Bun)", + }, + 501 + ); + return { upgrade: fallback }; + } + + const { upgradeWebSocket, websocket } = createBunWebSocketFn(); // Using bind to preserve 'this' context in the callback const upgrade = upgradeWebSocket((c) => { diff --git a/src/features/notifications/tests/notification.service.test.ts b/src/features/notifications/tests/notification.service.test.ts new file mode 100644 index 0000000..ca434d1 --- /dev/null +++ b/src/features/notifications/tests/notification.service.test.ts @@ -0,0 +1,251 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NotificationService } from '../application/services/notification.service'; +import { INotificationRepository } from '../domain/ports/notification-repository.port'; +import { PgUserRepository } from '@/users/infrastructure/adapters/user.repository'; +import { NotificationEmailService } from '../application/services/notification-email.service'; +import { NotificationUtilsService } from '../application/services/notification-utils.service'; +import * as HttpStatusCodes from 'stoker/http-status-codes'; +import { NotificationApiAdapter } from '../infrastructure/adapters/notification-api.adapter'; + +// Mocks +vi.mock('../infrastructure/adapters/notification-api.adapter', () => ({ + NotificationApiAdapter: { + toApiResponseList: vi.fn((data) => data), + toApiResponse: vi.fn((data) => data), + }, +})); + +vi.mock('@/users/infrastructure/adapters/user.repository', () => ({ + PgUserRepository: { + getInstance: vi.fn(), + }, +})); + +vi.mock('../application/services/notification-email.service', () => ({ + NotificationEmailService: { + getInstance: vi.fn().mockReturnValue({ + sendNotificationEmail: vi.fn(), + }), + }, +})); + +vi.mock('../application/services/notification-utils.service', () => ({ + NotificationUtilsService: { + getInstance: vi.fn().mockReturnValue({}), + }, +})); + +describe('NotificationService', () => { + let notificationService: NotificationService; + let notificationRepositoryMock: INotificationRepository; + let userRepositoryMock: any; + let notificationEmailServiceMock: any; + + beforeEach(() => { + notificationRepositoryMock = { + findAll: vi.fn(), + findById: vi.fn(), + findByUserId: vi.fn(), + findUnreadByUserId: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + markAsRead: vi.fn(), + markAllAsRead: vi.fn(), + deleteExpired: vi.fn(), + } as any; + + userRepositoryMock = { + findById: vi.fn(), + }; + (PgUserRepository.getInstance as any).mockReturnValue(userRepositoryMock); + + notificationEmailServiceMock = { + sendNotificationEmail: vi.fn(), + }; + (NotificationEmailService.getInstance as any).mockReturnValue(notificationEmailServiceMock); + + (NotificationService as any).instance = null; + notificationService = NotificationService.getInstance(notificationRepositoryMock); + }); + + describe('getAll', () => { + it('should return all notifications', async () => { + (notificationRepositoryMock.findAll as any).mockResolvedValue([]); + const c = { + json: vi.fn(), + }; + + await notificationService.getAll(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Notifications retrieved successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('getById', () => { + it('should return 404 if notification not found', async () => { + (notificationRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await notificationService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Notification not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return notification if found', async () => { + (notificationRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await notificationService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Notification retrieved successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('getByUserId', () => { + it('should return 404 if user not found', async () => { + (userRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await notificationService.getByUserId(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return user notifications if user found', async () => { + (userRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + (notificationRepositoryMock.findByUserId as any).mockResolvedValue([]); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await notificationService.getByUserId(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'User notifications retrieved successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('create', () => { + it('should return 404 if user not found', async () => { + (userRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { valid: vi.fn().mockReturnValue({ user_id: 1 }) }, + json: vi.fn(), + }; + + await notificationService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should create notification successfully', async () => { + (userRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + (notificationRepositoryMock.create as any).mockResolvedValue({ id: 1 }); + const c = { + req: { valid: vi.fn().mockReturnValue({ user_id: 1, title: 'Test', message: 'Test' }) }, + json: vi.fn(), + }; + + await notificationService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Notification created successfully' }), + HttpStatusCodes.CREATED + ); + }); + }); + + describe('update', () => { + it('should return 404 if notification not found', async () => { + (notificationRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1'), valid: vi.fn().mockReturnValue({}) }, + json: vi.fn(), + }; + + await notificationService.update(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Notification not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should update notification successfully', async () => { + (notificationRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + (notificationRepositoryMock.update as any).mockResolvedValue({ id: 1 }); + const c = { + req: { param: vi.fn().mockReturnValue('1'), valid: vi.fn().mockReturnValue({ title: 'New Title' }) }, + json: vi.fn(), + }; + + await notificationService.update(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Notification updated successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('delete', () => { + it('should return 404 if notification not found', async () => { + (notificationRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await notificationService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Notification not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should delete notification successfully', async () => { + (notificationRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + (notificationRepositoryMock.delete as any).mockResolvedValue(true); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await notificationService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Notification deleted successfully' }), + HttpStatusCodes.OK + ); + }); + }); +}); diff --git a/src/features/payment-methods/tests/payment-method.service.test.ts b/src/features/payment-methods/tests/payment-method.service.test.ts new file mode 100644 index 0000000..fa93dee --- /dev/null +++ b/src/features/payment-methods/tests/payment-method.service.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PaymentMethodService } from '../application/services/payment-method.service'; +import { IPaymentMethodRepository } from '@/payment-methods/domain/ports/payment-method-repository.port'; +import { PaymentMethodUtilsService } from '../application/services/payment-method-utils.service'; +import * as HttpStatusCodes from 'stoker/http-status-codes'; +import { PaymentMethodApiAdapter } from '@/payment-methods/infrastructure/adapters/payment-method-api.adapter'; + +vi.mock('@/payment-methods/infrastructure/adapters/payment-method-api.adapter', () => ({ + PaymentMethodApiAdapter: { + toApiResponseList: vi.fn((data) => data), + toApiResponse: vi.fn((data) => data), + }, +})); + +describe('PaymentMethodService', () => { + let paymentMethodService: PaymentMethodService; + let paymentMethodRepositoryMock: IPaymentMethodRepository; + let paymentMethodUtilsMock: PaymentMethodUtilsService; + + beforeEach(() => { + paymentMethodRepositoryMock = { + findAll: vi.fn(), + findById: vi.fn(), + findByUserId: vi.fn(), + findSharedWithUser: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + } as any; + + paymentMethodUtilsMock = { + validateUser: vi.fn(), + validateSharing: vi.fn(), + } as any; + + (PaymentMethodService as any).instance = null; + paymentMethodService = PaymentMethodService.getInstance( + paymentMethodRepositoryMock, + paymentMethodUtilsMock + ); + }); + + describe('getAll', () => { + it('should return all payment methods', async () => { + const paymentMethods = [{ id: 1, name: 'Card' }]; + (paymentMethodRepositoryMock.findAll as any).mockResolvedValue(paymentMethods); + const c = { + json: vi.fn(), + }; + + await paymentMethodService.getAll(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.arrayContaining([expect.objectContaining({ name: 'Card' })]), + }), + HttpStatusCodes.OK + ); + }); + }); + + describe('getById', () => { + it('should return 404 if payment method not found', async () => { + (paymentMethodRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await paymentMethodService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Payment method not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return payment method if found', async () => { + const paymentMethod = { id: 1, name: 'Card' }; + (paymentMethodRepositoryMock.findById as any).mockResolvedValue(paymentMethod); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await paymentMethodService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ name: 'Card' }), + }), + HttpStatusCodes.OK + ); + }); + }); + + describe('create', () => { + it('should return 400 if shared user not found', async () => { + (paymentMethodUtilsMock.validateUser as any).mockResolvedValue({ isValid: false }); + const c = { + req: { valid: vi.fn().mockReturnValue({ user_id: 1, shared_user_id: 2 }) }, + json: vi.fn(), + }; + + await paymentMethodService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Shared user not found' }), + HttpStatusCodes.BAD_REQUEST + ); + }); + + it('should create payment method successfully', async () => { + const paymentMethodData = { + user_id: 1, + name: 'New Card', + type: 'CARD', + last_four_digits: '1234', + }; + const createdPaymentMethod = { id: 1, ...paymentMethodData }; + (paymentMethodRepositoryMock.create as any).mockResolvedValue(createdPaymentMethod); + const c = { + req: { valid: vi.fn().mockReturnValue(paymentMethodData) }, + json: vi.fn(), + }; + + await paymentMethodService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ name: 'New Card' }), + }), + HttpStatusCodes.CREATED + ); + }); + }); + + describe('update', () => { + it('should return 404 if payment method not found', async () => { + (paymentMethodRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1'), valid: vi.fn().mockReturnValue({}) }, + json: vi.fn(), + }; + + await paymentMethodService.update(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Payment method not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should update payment method successfully', async () => { + const existingPaymentMethod = { id: 1, name: 'Old Name', userId: 1 }; + (paymentMethodRepositoryMock.findById as any).mockResolvedValue(existingPaymentMethod); + const updateData = { name: 'New Name' }; + const updatedPaymentMethod = { ...existingPaymentMethod, ...updateData }; + (paymentMethodRepositoryMock.update as any).mockResolvedValue(updatedPaymentMethod); + + const c = { + req: { param: vi.fn().mockReturnValue('1'), valid: vi.fn().mockReturnValue(updateData) }, + json: vi.fn(), + }; + + await paymentMethodService.update(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ name: 'New Name' }), + }), + HttpStatusCodes.OK + ); + }); + }); + + describe('delete', () => { + it('should return 404 if payment method not found', async () => { + (paymentMethodRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await paymentMethodService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Payment method not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should delete payment method successfully', async () => { + (paymentMethodRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + (paymentMethodRepositoryMock.delete as any).mockResolvedValue(true); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await paymentMethodService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: { deleted: true }, + }), + HttpStatusCodes.OK + ); + }); + }); +}); diff --git a/src/features/recommendations/application/dtos/recommendation.dto.ts b/src/features/recommendations/application/dtos/recommendation.dto.ts new file mode 100644 index 0000000..81fe36c --- /dev/null +++ b/src/features/recommendations/application/dtos/recommendation.dto.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { + RecommendationType, + RecommendationPriority, + RecommendationStatus, +} from "../../domain/entities/recommendation.types"; +import { + Recommendation, + QuickAction, +} from "../../domain/entities/recommendation.entity"; + +export const createRecommendationSchema = z.object({ + userId: z.number(), + type: z.enum([ + "SPENDING_ANALYSIS", + "GOAL_OPTIMIZATION", + "BUDGET_SUGGESTION", + "DEBT_REMINDER", + ]), + priority: z.enum(["HIGH", "MEDIUM", "LOW"]), + title: z.string().min(1).max(255), + description: z.string().min(1), + data: z.any().optional(), + actionable: z.boolean().default(false), + actions: z + .array( + z.object({ + label: z.string(), + path: z.string(), + prefilledData: z.record(z.any()).optional(), + }) + ) + .optional(), + expiresAt: z.date().optional(), +}); + +export type CreateRecommendationDTO = z.infer< + typeof createRecommendationSchema +>; + +export const updateStatusSchema = z.object({ + status: z.enum(["VIEWED", "DISMISSED", "ACTED"]), +}); + +export type UpdateStatusDTO = z.infer; + +export interface RecommendationResponseDTO { + id: number; + type: RecommendationType; + priority: RecommendationPriority; + title: string; + description: string; + data?: any; + actionable: boolean; + actions?: QuickAction[]; + status: RecommendationStatus; + createdAt: string; + expiresAt?: string; +} + +export function toResponseDTO(rec: Recommendation): RecommendationResponseDTO { + return { + id: rec.id, + type: rec.type, + priority: rec.priority, + title: rec.title, + description: rec.description, + data: rec.data, + actionable: rec.actionable, + actions: rec.actions, + status: rec.status, + createdAt: rec.createdAt.toISOString(), + expiresAt: rec.expiresAt?.toISOString(), + }; +} diff --git a/src/features/recommendations/application/services/budget-suggestion.service.ts b/src/features/recommendations/application/services/budget-suggestion.service.ts new file mode 100644 index 0000000..edb0384 --- /dev/null +++ b/src/features/recommendations/application/services/budget-suggestion.service.ts @@ -0,0 +1,255 @@ +import { + IAnalysisService, + AnalysisResult, +} from "../../domain/ports/analysis.service.interface"; +import { RecommendationPriority } from "../../domain/entities/recommendation.types"; +import { PgTransactionRepository } from "@/transactions/infrastructure/adapters/transaction.repository"; +import { PgBudgetRepository } from "@/budgets/infrastructure/adapters/budget.repository"; +import { PgCategoryRepository } from "@/categories/infrastructure/adapters/category.repository"; + +interface CategoryWithoutBudget { + categoryId: number; + categoryName: string; + averageMonthlySpending: number; +} + +export class BudgetSuggestionService implements IAnalysisService { + private static instance: BudgetSuggestionService; + private transactionRepository: PgTransactionRepository; + private budgetRepository: PgBudgetRepository; + private categoryRepository: PgCategoryRepository; + private readonly OPENAI_API_KEY = process.env.OPENAI_API_KEY || ""; + private readonly MIN_AVERAGE_SPENDING = 50; + + private constructor() { + this.transactionRepository = PgTransactionRepository.getInstance(); + this.budgetRepository = PgBudgetRepository.getInstance(); + this.categoryRepository = PgCategoryRepository.getInstance(); + } + + public static getInstance(): BudgetSuggestionService { + if (!BudgetSuggestionService.instance) { + BudgetSuggestionService.instance = new BudgetSuggestionService(); + } + return BudgetSuggestionService.instance; + } + + async analyze(userId: number): Promise { + try { + const categoryNeedingBudget = await this.findCategoryNeedingBudget( + userId + ); + + if (!categoryNeedingBudget) { + return null; + } + + const aiSuggestion = await this.getAISuggestion(categoryNeedingBudget); + + const priority = this.calculatePriority( + categoryNeedingBudget.averageMonthlySpending + ); + + return { + shouldGenerate: true, + priority, + title: `Crear presupuesto para ${categoryNeedingBudget.categoryName}`, + description: aiSuggestion.reasoning, + data: { + categoryId: categoryNeedingBudget.categoryId, + categoryName: categoryNeedingBudget.categoryName, + averageSpending: categoryNeedingBudget.averageMonthlySpending, + suggestedBudget: aiSuggestion.suggestedBudget, + }, + actions: [ + { + label: "Crear presupuesto", + path: "/management/budgets/create", + prefilledData: { + categoryId: categoryNeedingBudget.categoryId, + limitAmount: aiSuggestion.suggestedBudget, + month: this.getCurrentMonthString(), + }, + }, + { + label: "Ver gastos", + path: "/management/transactions", + prefilledData: { + categoryId: categoryNeedingBudget.categoryId, + type: "EXPENSE", + }, + }, + ], + }; + } catch (error) { + console.error("Error in BudgetSuggestionService:", error); + return null; + } + } + + private async findCategoryNeedingBudget( + userId: number + ): Promise { + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + + const recentExpenses = await this.transactionRepository.findByFilters( + userId, + { + type: "EXPENSE", + startDate: threeMonthsAgo.toISOString(), + endDate: new Date().toISOString(), + } + ); + + const currentMonthBudgets = + await this.budgetRepository.findByUserIdAndMonth( + userId, + new Date(this.getCurrentMonthString()) + ); + + const budgetedCategoryIds = new Set( + currentMonthBudgets.map((b) => b.categoryId).filter((id) => id !== null) + ); + + const spendingByCategory: Record = + {}; + + for (const expense of recentExpenses) { + if (!expense.categoryId) continue; + + if (budgetedCategoryIds.has(expense.categoryId)) continue; + + if (!spendingByCategory[expense.categoryId]) { + spendingByCategory[expense.categoryId] = { + name: expense.category?.name || "Sin nombre", + total: 0, + }; + } + + spendingByCategory[expense.categoryId].total += expense.amount; + } + + const categoriesNeedingBudget: CategoryWithoutBudget[] = []; + + for (const [categoryIdStr, data] of Object.entries(spendingByCategory)) { + const categoryId = Number(categoryIdStr); + const averageMonthlySpending = data.total / 3; + + if (averageMonthlySpending >= this.MIN_AVERAGE_SPENDING) { + categoriesNeedingBudget.push({ + categoryId, + categoryName: data.name, + averageMonthlySpending: Math.round(averageMonthlySpending), + }); + } + } + + if (categoriesNeedingBudget.length === 0) { + return null; + } + + categoriesNeedingBudget.sort( + (a, b) => b.averageMonthlySpending - a.averageMonthlySpending + ); + + return categoriesNeedingBudget[0]; + } + + private async getAISuggestion( + category: CategoryWithoutBudget + ): Promise<{ suggestedBudget: number; reasoning: string }> { + const prompt = `El usuario gasta en promedio $${category.averageMonthlySpending.toFixed( + 2 + )}/mes en "${ + category.categoryName + }" pero no tiene un presupuesto establecido para esta categoría. + +Sugiere un presupuesto mensual razonable que: +1. Incluya un buffer del 10-20% para flexibilidad +2. Sea realista basado en su patrón de gasto actual +3. Le ayude a controlar sus gastos sin ser demasiado restrictivo + +Proporciona un razonamiento breve (2-3 oraciones) sobre por qué este presupuesto es apropiado. + +Responde SOLO con un JSON válido en este formato: +{ + "suggestedBudget": número (monto sugerido para el presupuesto mensual), + "reasoning": "explicación breve sobre el presupuesto sugerido" +}`; + + try { + const response = await fetch( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + Authorization: `Bearer ${this.OPENAI_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4", + messages: [ + { + role: "system", + content: + "Eres un asesor financiero experto en presupuestos personales. Proporciona recomendaciones prácticas y motivadoras en español.", + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + max_tokens: 300, + response_format: { type: "json_object" }, + }), + } + ); + + if (!response.ok) { + throw new Error(`OpenAI API error: ${response.status}`); + } + + const result = await response.json(); + const content = result.choices[0]?.message?.content; + + if (!content) { + throw new Error("Empty response from OpenAI"); + } + + return JSON.parse(content); + } catch (error) { + console.error("Error calling OpenAI API:", error); + + const suggestedBudget = Math.ceil(category.averageMonthlySpending * 1.15); + + return { + suggestedBudget, + reasoning: `Basado en tu gasto promedio de $${category.averageMonthlySpending.toFixed( + 2 + )}, te sugerimos un presupuesto de $${suggestedBudget.toFixed( + 2 + )} mensual que incluye un 15% de flexibilidad para imprevistos en ${ + category.categoryName + }.`, + }; + } + } + + private calculatePriority(averageSpending: number): RecommendationPriority { + if (averageSpending >= 500) { + return RecommendationPriority.HIGH; + } else if (averageSpending >= 200) { + return RecommendationPriority.MEDIUM; + } + return RecommendationPriority.LOW; + } + + private getCurrentMonthString(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + return `${year}-${month}`; + } +} diff --git a/src/features/recommendations/application/services/debt-reminder.service.ts b/src/features/recommendations/application/services/debt-reminder.service.ts new file mode 100644 index 0000000..c8e099d --- /dev/null +++ b/src/features/recommendations/application/services/debt-reminder.service.ts @@ -0,0 +1,256 @@ +import { + IAnalysisService, + AnalysisResult, +} from "../../domain/ports/analysis.service.interface"; +import { RecommendationPriority } from "../../domain/entities/recommendation.types"; +import { PgDebtRepository } from "@/debts/infrastructure/adapters/debt.repository"; +import { PgTransactionRepository } from "@/transactions/infrastructure/adapters/transaction.repository"; +import { IDebt } from "@/debts/domain/entities/IDebt"; + +interface DebtOpportunity { + debt: IDebt; + daysUntilDue: number; + availableBalance: number; + suggestedPayment: number; + canPayFull: boolean; +} + +export class DebtReminderService implements IAnalysisService { + private static instance: DebtReminderService; + private debtRepository: PgDebtRepository; + private transactionRepository: PgTransactionRepository; + private readonly DAYS_AHEAD_THRESHOLD = 15; + private readonly OPENAI_API_KEY = process.env.OPENAI_API_KEY || ""; + + private constructor() { + this.debtRepository = PgDebtRepository.getInstance(); + this.transactionRepository = PgTransactionRepository.getInstance(); + } + + public static getInstance(): DebtReminderService { + if (!DebtReminderService.instance) { + DebtReminderService.instance = new DebtReminderService(); + } + return DebtReminderService.instance; + } + + async analyze(userId: number): Promise { + try { + const debtOpportunity = await this.findPaymentOpportunity(userId); + + if (!debtOpportunity) { + return null; + } + + const aiSuggestion = await this.getAISuggestion(debtOpportunity); + + const priority = this.calculatePriority(debtOpportunity.daysUntilDue); + + return { + shouldGenerate: true, + priority, + title: `Oportunidad de pago: ${debtOpportunity.debt.description}`, + description: aiSuggestion.reasoning, + data: { + debtId: debtOpportunity.debt.id, + debtName: debtOpportunity.debt.description, + pendingAmount: debtOpportunity.debt.pendingAmount, + daysUntilDue: debtOpportunity.daysUntilDue, + dueDate: debtOpportunity.debt.dueDate.toISOString(), + availableBalance: debtOpportunity.availableBalance, + suggestedPayment: aiSuggestion.suggestedPayment, + canPayFull: debtOpportunity.canPayFull, + }, + actions: [ + { + label: debtOpportunity.canPayFull + ? "Pagar deuda completa" + : "Hacer pago parcial", + path: `/management/debts/${debtOpportunity.debt.id}/pay`, + prefilledData: { + amount: aiSuggestion.suggestedPayment, + }, + }, + { + label: "Ver detalles de deuda", + path: `/management/debts/${debtOpportunity.debt.id}`, + prefilledData: {}, + }, + ], + }; + } catch (error) { + console.error("Error in DebtReminderService:", error); + return null; + } + } + + private async findPaymentOpportunity( + userId: number + ): Promise { + const userDebts = await this.debtRepository.findByUserId(userId); + + const activeDebts = userDebts.filter( + (debt) => !debt.paid && debt.pendingAmount > 0 + ); + + if (activeDebts.length === 0) { + return null; + } + + const now = new Date(); + const upcomingDebts = activeDebts.filter((debt) => { + const daysUntilDue = Math.ceil( + (debt.dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) + ); + return daysUntilDue > 0 && daysUntilDue <= this.DAYS_AHEAD_THRESHOLD; + }); + + if (upcomingDebts.length === 0) { + return null; + } + + const availableBalance = await this.calculateAvailableBalance(userId); + + if (availableBalance <= 0) { + return null; + } + + const opportunities: DebtOpportunity[] = []; + + for (const debt of upcomingDebts) { + const daysUntilDue = Math.ceil( + (debt.dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) + ); + + const canPayFull = availableBalance >= debt.pendingAmount; + + const suggestedPayment = canPayFull + ? debt.pendingAmount + : Math.min( + Math.floor(availableBalance * 0.5), + debt.pendingAmount + ); + + if (suggestedPayment > 0) { + opportunities.push({ + debt, + daysUntilDue, + availableBalance, + suggestedPayment, + canPayFull, + }); + } + } + + if (opportunities.length === 0) { + return null; + } + + opportunities.sort((a, b) => a.daysUntilDue - b.daysUntilDue); + + return opportunities[0]; + } + + private async calculateAvailableBalance(userId: number): Promise { + const now = new Date(); + const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + const monthlyBalance = await this.transactionRepository.getMonthlyBalance( + userId, + firstDayOfMonth + ); + + return monthlyBalance.balance; + } + + private async getAISuggestion( + opportunity: DebtOpportunity + ): Promise<{ suggestedPayment: number; reasoning: string }> { + const prompt = `El usuario tiene disponible $${opportunity.availableBalance.toFixed(2)} este mes. La deuda "${opportunity.debt.description}" de $${opportunity.debt.pendingAmount.toFixed(2)} vence en ${opportunity.daysUntilDue} días. + +${ + opportunity.canPayFull + ? "Tiene suficiente para pagar la deuda completa." + : `No puede pagar la deuda completa, pero puede hacer un pago parcial significativo.` +} + +Sugiere cuánto debería pagar ahora considerando: +1. El monto disponible +2. Los días restantes hasta el vencimiento +3. Mantener un colchón de emergencia (no usar todo el balance disponible) +4. ${opportunity.canPayFull ? "Beneficios de liquidar la deuda completa" : "Reducir el saldo pendiente para evitar intereses"} + +Proporciona un razonamiento motivador (2-3 oraciones) sobre por qué es importante hacer este pago ahora. + +Responde SOLO con un JSON válido en este formato: +{ + "suggestedPayment": número (monto sugerido para pagar), + "reasoning": "explicación breve y motivadora" +}`; + + try { + const response = await fetch( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + Authorization: `Bearer ${this.OPENAI_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4", + messages: [ + { + role: "system", + content: + "Eres un asesor financiero experto en gestión de deudas. Proporciona consejos prácticos y motivadores en español.", + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + max_tokens: 300, + response_format: { type: "json_object" }, + }), + } + ); + + if (!response.ok) { + throw new Error(`OpenAI API error: ${response.status}`); + } + + const result = await response.json(); + const content = result.choices[0]?.message?.content; + + if (!content) { + throw new Error("Empty response from OpenAI"); + } + + return JSON.parse(content); + } catch (error) { + console.error("Error calling OpenAI API:", error); + + const suggestedPayment = opportunity.canPayFull + ? opportunity.debt.pendingAmount + : Math.floor(opportunity.availableBalance * 0.4); + + return { + suggestedPayment, + reasoning: opportunity.canPayFull + ? `Tienes suficiente saldo para liquidar esta deuda de $${opportunity.debt.pendingAmount.toFixed(2)}. Pagarla ahora te liberará de esta obligación y evitará posibles cargos por intereses.` + : `Con tu saldo actual, puedes hacer un pago de $${suggestedPayment.toFixed(2)} que reducirá significativamente tu deuda. Esto te acercará a liquidarla y minimizará los intereses acumulados.`, + }; + } + } + + private calculatePriority(daysUntilDue: number): RecommendationPriority { + if (daysUntilDue <= 5) { + return RecommendationPriority.HIGH; + } else if (daysUntilDue <= 10) { + return RecommendationPriority.MEDIUM; + } + return RecommendationPriority.LOW; + } +} diff --git a/src/features/recommendations/application/services/goal-optimization.service.ts b/src/features/recommendations/application/services/goal-optimization.service.ts new file mode 100644 index 0000000..9ed0f4c --- /dev/null +++ b/src/features/recommendations/application/services/goal-optimization.service.ts @@ -0,0 +1,284 @@ +import { + IAnalysisService, + AnalysisResult, +} from "../../domain/ports/analysis.service.interface"; +import { RecommendationPriority } from "../../domain/entities/recommendation.types"; +import { PgGoalRepository } from "@/goals/infrastucture/adapters/goal.repository"; +import { PgGoalContributionRepository } from "@/goals/infrastucture/adapters/goal-contribution.repository"; +import { IGoal } from "@/goals/domain/entities/IGoal"; + +interface GoalAnalysis { + goal: IGoal; + daysRemaining: number; + amountRemaining: number; + dailyRequiredContribution: number; + averageContribution: number; + isUnrealistic: boolean; + realismRatio: number; +} + +export class GoalOptimizationService implements IAnalysisService { + private static instance: GoalOptimizationService; + private goalRepository: PgGoalRepository; + private contributionRepository: PgGoalContributionRepository; + private readonly UNREALISTIC_THRESHOLD = 2; + private readonly OPENAI_API_KEY = process.env.OPENAI_API_KEY || ""; + + private constructor() { + this.goalRepository = PgGoalRepository.getInstance(); + this.contributionRepository = PgGoalContributionRepository.getInstance(); + } + + public static getInstance(): GoalOptimizationService { + if (!GoalOptimizationService.instance) { + GoalOptimizationService.instance = new GoalOptimizationService(); + } + return GoalOptimizationService.instance; + } + + async analyze(userId: number): Promise { + try { + const unrealisticGoal = await this.findUnrealisticGoal(userId); + + if (!unrealisticGoal) { + return null; + } + + const aiSuggestion = await this.getAISuggestion(unrealisticGoal); + + const priority = this.calculatePriority(unrealisticGoal.realismRatio); + + const suggestedValues = this.calculateSuggestedValues(unrealisticGoal); + + return { + shouldGenerate: true, + priority, + title: `Meta "${unrealisticGoal.goal.name}" necesita ajuste`, + description: aiSuggestion.reasoning, + data: { + goalId: unrealisticGoal.goal.id, + goalName: unrealisticGoal.goal.name, + daysRemaining: unrealisticGoal.daysRemaining, + amountRemaining: unrealisticGoal.amountRemaining, + dailyRequired: unrealisticGoal.dailyRequiredContribution, + averageContribution: unrealisticGoal.averageContribution, + realismRatio: unrealisticGoal.realismRatio, + suggestionType: aiSuggestion.suggestion, + suggestedNewValue: aiSuggestion.newValue, + suggestedTargetAmount: suggestedValues.targetAmount, + suggestedEndDate: suggestedValues.endDate, + }, + actions: [ + { + label: "Ajustar meta", + path: `/management/goals/${unrealisticGoal.goal.id}/edit`, + prefilledData: + aiSuggestion.suggestion === "extend" + ? { + endDate: suggestedValues.endDate, + } + : { + targetAmount: suggestedValues.targetAmount, + }, + }, + { + label: "Ver contribuciones", + path: `/management/goals/${unrealisticGoal.goal.id}`, + prefilledData: {}, + }, + ], + }; + } catch (error) { + console.error("Error in GoalOptimizationService:", error); + return null; + } + } + + private async findUnrealisticGoal( + userId: number + ): Promise { + const activeGoals = await this.goalRepository.findAllActive(); + + const userGoals = activeGoals.filter( + (goal) => goal.userId === userId || goal.sharedUserId === userId + ); + + if (userGoals.length === 0) { + return null; + } + + const goalAnalyses: GoalAnalysis[] = []; + + for (const goal of userGoals) { + const contributions = await this.contributionRepository.findByGoalId( + goal.id + ); + + if (contributions.length === 0) continue; + + const daysRemaining = Math.ceil( + (goal.endDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24) + ); + + if (daysRemaining <= 0) continue; + + const amountRemaining = goal.targetAmount - goal.currentAmount; + + if (amountRemaining <= 0) continue; + + const dailyRequiredContribution = amountRemaining / daysRemaining; + + const totalContributions = contributions.reduce( + (sum, c) => sum + c.amount, + 0 + ); + const firstContribution = contributions[contributions.length - 1]; + const daysSinceStart = Math.ceil( + (Date.now() - firstContribution.date.getTime()) / (1000 * 60 * 60 * 24) + ); + + const averageContribution = + daysSinceStart > 0 ? totalContributions / daysSinceStart : 0; + + if (averageContribution === 0) continue; + + const realismRatio = dailyRequiredContribution / averageContribution; + + const isUnrealistic = realismRatio > this.UNREALISTIC_THRESHOLD; + + if (isUnrealistic) { + goalAnalyses.push({ + goal, + daysRemaining, + amountRemaining, + dailyRequiredContribution, + averageContribution, + isUnrealistic, + realismRatio, + }); + } + } + + if (goalAnalyses.length === 0) { + return null; + } + + goalAnalyses.sort((a, b) => b.realismRatio - a.realismRatio); + + return goalAnalyses[0]; + } + + private async getAISuggestion( + analysis: GoalAnalysis + ): Promise<{ suggestion: "extend" | "reduce"; newValue: number; reasoning: string }> { + const prompt = `La meta "${analysis.goal.name}" del usuario requiere ahorrar $${analysis.dailyRequiredContribution.toFixed(2)}/día durante los próximos ${analysis.daysRemaining} días para alcanzar $${analysis.amountRemaining.toFixed(2)} faltantes. + +Su contribución promedio histórica es de $${analysis.averageContribution.toFixed(2)}/día, lo cual hace esta meta ${Math.round(analysis.realismRatio)}x más exigente de lo que ha logrado mantener. + +Sugiere: +1. Extender la fecha límite (¿cuántos días más necesitaría manteniendo su ritmo actual?) +2. Reducir el monto objetivo (¿a qué monto realista podría llegar con su ritmo actual en el tiempo restante?) + +Proporciona un razonamiento breve (2-3 oraciones) y la recomendación específica. + +Responde SOLO con un JSON válido en este formato: +{ + "suggestion": "extend" o "reduce", + "newValue": número (días adicionales si extend, o nuevo monto objetivo si reduce), + "reasoning": "explicación breve y motivadora" +}`; + + try { + const response = await fetch( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + Authorization: `Bearer ${this.OPENAI_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4", + messages: [ + { + role: "system", + content: + "Eres un asesor financiero experto en establecimiento de metas realistas. Proporciona consejos motivadores y prácticos en español.", + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + max_tokens: 300, + response_format: { type: "json_object" }, + }), + } + ); + + if (!response.ok) { + throw new Error(`OpenAI API error: ${response.status}`); + } + + const result = await response.json(); + const content = result.choices[0]?.message?.content; + + if (!content) { + throw new Error("Empty response from OpenAI"); + } + + return JSON.parse(content); + } catch (error) { + console.error("Error calling OpenAI API:", error); + + const daysNeeded = Math.ceil( + analysis.amountRemaining / analysis.averageContribution + ); + const additionalDays = daysNeeded - analysis.daysRemaining; + + return { + suggestion: additionalDays > 0 ? "extend" : "reduce", + newValue: + additionalDays > 0 + ? additionalDays + : Math.floor( + analysis.averageContribution * analysis.daysRemaining + ), + reasoning: + "Tu ritmo de ahorro actual sugiere que necesitas ajustar tu meta para hacerla más alcanzable. Esto te ayudará a mantener la motivación y el progreso constante.", + }; + } + } + + private calculateSuggestedValues(analysis: GoalAnalysis): { + targetAmount: number; + endDate: string; + } { + const daysNeeded = Math.ceil( + analysis.amountRemaining / analysis.averageContribution + ); + + const realisticAmount = Math.floor( + analysis.goal.currentAmount + + analysis.averageContribution * analysis.daysRemaining + ); + + const extendedDate = new Date(analysis.goal.endDate); + extendedDate.setDate(extendedDate.getDate() + (daysNeeded - analysis.daysRemaining)); + + return { + targetAmount: realisticAmount, + endDate: extendedDate.toISOString(), + }; + } + + private calculatePriority(realismRatio: number): RecommendationPriority { + if (realismRatio >= 3) { + return RecommendationPriority.HIGH; + } else if (realismRatio >= 2) { + return RecommendationPriority.MEDIUM; + } + return RecommendationPriority.LOW; + } +} diff --git a/src/features/recommendations/application/services/recommendation-orchestrator.service.ts b/src/features/recommendations/application/services/recommendation-orchestrator.service.ts new file mode 100644 index 0000000..c971192 --- /dev/null +++ b/src/features/recommendations/application/services/recommendation-orchestrator.service.ts @@ -0,0 +1,203 @@ +import { IRecommendationOrchestrator } from "../../domain/ports/orchestrator.service.interface"; +import { IRecommendationRepository } from "../../domain/ports/recommendation.repository.interface"; +import { IAnalysisService } from "../../domain/ports/analysis.service.interface"; +import { Recommendation } from "../../domain/entities/recommendation.entity"; +import { RecommendationType } from "../../domain/entities/recommendation.types"; +import { SpendingAnalysisService } from "./spending-analysis.service"; +import { GoalOptimizationService } from "./goal-optimization.service"; +import { BudgetSuggestionService } from "./budget-suggestion.service"; +import { DebtReminderService } from "./debt-reminder.service"; +import { PgNotificationRepository } from "@/notifications/infrastructure/adapters/notification.repository"; +import { NotificationType } from "@/notifications/domain/entities/INotification"; +import { db } from "@/db"; +import { users } from "@/schema"; +import { eq, and, gte } from "drizzle-orm"; + +export class RecommendationOrchestratorService + implements IRecommendationOrchestrator +{ + private static instance: RecommendationOrchestratorService; + private services: Map; + private notificationRepository: PgNotificationRepository; + + private constructor( + private readonly recommendationRepository: IRecommendationRepository + ) { + this.services = new Map([ + [ + RecommendationType.SPENDING_ANALYSIS, + SpendingAnalysisService.getInstance(), + ], + [ + RecommendationType.GOAL_OPTIMIZATION, + GoalOptimizationService.getInstance(), + ], + [ + RecommendationType.BUDGET_SUGGESTION, + BudgetSuggestionService.getInstance(), + ], + [RecommendationType.DEBT_REMINDER, DebtReminderService.getInstance()], + ]); + + this.notificationRepository = PgNotificationRepository.getInstance(); + } + + public static getInstance( + recommendationRepository: IRecommendationRepository + ): RecommendationOrchestratorService { + if (!RecommendationOrchestratorService.instance) { + RecommendationOrchestratorService.instance = + new RecommendationOrchestratorService(recommendationRepository); + } + return RecommendationOrchestratorService.instance; + } + + async generateDailyRecommendation( + userId: number + ): Promise { + try { + const isEnabled = await this.checkUserRecommendationsEnabled(userId); + if (!isEnabled) { + console.log( + `User ${userId} does not have recommendations enabled. Skipping.` + ); + return null; + } + + const hasRecentRecommendation = await this.checkRecentRecommendation( + userId + ); + if (hasRecentRecommendation) { + console.log( + `User ${userId} already has a pending recommendation from today. Skipping.` + ); + return null; + } + + const randomType = this.selectRandomType(); + console.log( + `Selected recommendation type for user ${userId}: ${randomType}` + ); + + const service = this.services.get(randomType); + if (!service) { + console.error(`No service found for type: ${randomType}`); + return null; + } + + const analysisResult = await service.analyze(userId); + if (!analysisResult || !analysisResult.shouldGenerate) { + console.log( + `No recommendation generated for user ${userId} with type ${randomType}` + ); + return null; + } + + const recommendation = await this.recommendationRepository.create({ + userId, + type: randomType, + priority: analysisResult.priority, + title: analysisResult.title, + description: analysisResult.description, + data: analysisResult.data, + actionable: + !!analysisResult.actions && analysisResult.actions.length > 0, + actions: analysisResult.actions, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + }); + + await this.createNotification(recommendation); + + console.log( + `Successfully created recommendation ${recommendation.id} for user ${userId}` + ); + + return recommendation; + } catch (error) { + console.error( + `Error generating daily recommendation for user ${userId}:`, + error + ); + return null; + } + } + + private async checkUserRecommendationsEnabled( + userId: number + ): Promise { + try { + const [user] = await db + .select({ recommendationsEnabled: users.recommendations_enabled }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + return user?.recommendationsEnabled || false; + } catch (error) { + console.error( + `Error checking recommendations_enabled for user ${userId}:`, + error + ); + return false; + } + } + + private async checkRecentRecommendation(userId: number): Promise { + try { + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + + const pendingRecommendations = + await this.recommendationRepository.findPendingByUserId(userId); + + const hasRecentRecommendation = pendingRecommendations.some( + (rec) => rec.createdAt >= todayStart + ); + + return hasRecentRecommendation; + } catch (error) { + console.error( + `Error checking recent recommendations for user ${userId}:`, + error + ); + return false; + } + } + + private selectRandomType(): RecommendationType { + const types = Object.values(RecommendationType); + const randomIndex = Math.floor(Math.random() * types.length); + return types[randomIndex]; + } + + private async createNotification( + recommendation: Recommendation + ): Promise { + try { + await this.notificationRepository.create({ + userId: recommendation.userId, + title: recommendation.title, + subtitle: this.getTypeLabel(recommendation.type), + message: recommendation.description, + type: NotificationType.SUGGESTION, + expiresAt: recommendation.expiresAt || undefined, + }); + } catch (error) { + console.error( + `Error creating notification for recommendation ${recommendation.id}:`, + error + ); + } + } + + private getTypeLabel(type: RecommendationType): string { + const labels: Record = { + [RecommendationType.SPENDING_ANALYSIS]: "Análisis de Gastos", + [RecommendationType.GOAL_OPTIMIZATION]: "Optimización de Metas", + [RecommendationType.BUDGET_SUGGESTION]: "Sugerencia de Presupuesto", + [RecommendationType.DEBT_REMINDER]: "Recordatorio de Deuda", + }; + + return labels[type]; + } +} diff --git a/src/features/recommendations/application/services/spending-analysis.service.ts b/src/features/recommendations/application/services/spending-analysis.service.ts new file mode 100644 index 0000000..d031f50 --- /dev/null +++ b/src/features/recommendations/application/services/spending-analysis.service.ts @@ -0,0 +1,252 @@ +import { + IAnalysisService, + AnalysisResult, +} from "../../domain/ports/analysis.service.interface"; +import { RecommendationPriority } from "../../domain/entities/recommendation.types"; +import { PgTransactionRepository } from "@/transactions/infrastructure/adapters/transaction.repository"; +import { sql } from "drizzle-orm"; + +interface CategorySpending { + categoryId: number | null; + categoryName: string; + currentMonthTotal: number; + threeMonthAverage: number; + percentageIncrease: number; +} + +export class SpendingAnalysisService implements IAnalysisService { + private static instance: SpendingAnalysisService; + private transactionRepository: PgTransactionRepository; + private readonly THRESHOLD_PERCENTAGE = 20; + private readonly OPENAI_API_KEY = process.env.OPENAI_API_KEY || ""; + + private constructor() { + this.transactionRepository = PgTransactionRepository.getInstance(); + } + + public static getInstance(): SpendingAnalysisService { + if (!SpendingAnalysisService.instance) { + SpendingAnalysisService.instance = new SpendingAnalysisService(); + } + return SpendingAnalysisService.instance; + } + + async analyze(userId: number): Promise { + try { + const unusualSpending = await this.findUnusualSpending(userId); + + if (!unusualSpending) { + return null; + } + + const aiInsight = await this.getAIInsight(unusualSpending); + + const priority = this.calculatePriority( + unusualSpending.percentageIncrease + ); + + return { + shouldGenerate: true, + priority, + title: `Gasto inusual en ${unusualSpending.categoryName}`, + description: aiInsight.insight, + data: { + categoryId: unusualSpending.categoryId, + categoryName: unusualSpending.categoryName, + currentAmount: unusualSpending.currentMonthTotal, + averageAmount: unusualSpending.threeMonthAverage, + percentageIncrease: unusualSpending.percentageIncrease, + suggestion: aiInsight.suggestion, + }, + actions: [ + { + label: "Ver transacciones", + path: "/management/transactions", + prefilledData: { + categoryId: unusualSpending.categoryId, + startDate: this.getFirstDayOfCurrentMonth(), + endDate: new Date().toISOString(), + }, + }, + { + label: "Crear presupuesto", + path: "/management/budgets/create", + prefilledData: { + categoryId: unusualSpending.categoryId, + limitAmount: Math.ceil(unusualSpending.threeMonthAverage * 1.1), + }, + }, + ], + }; + } catch (error) { + console.error("Error in SpendingAnalysisService:", error); + return null; + } + } + + private async findUnusualSpending( + userId: number + ): Promise { + const now = new Date(); + const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const threeMonthsAgo = new Date( + now.getFullYear(), + now.getMonth() - 3, + 1 + ); + const previousMonthStart = new Date( + now.getFullYear(), + now.getMonth() - 1, + 1 + ); + + const currentMonthExpenses = await this.transactionRepository.findByFilters( + userId, + { + type: "EXPENSE", + startDate: currentMonthStart.toISOString(), + endDate: now.toISOString(), + } + ); + + const previousThreeMonthsExpenses = + await this.transactionRepository.findByFilters(userId, { + type: "EXPENSE", + startDate: threeMonthsAgo.toISOString(), + endDate: previousMonthStart.toISOString(), + }); + + const currentMonthByCategory = this.groupByCategory(currentMonthExpenses); + const previousMonthsByCategory = this.groupByCategory( + previousThreeMonthsExpenses + ); + + const categoryAnalysis: CategorySpending[] = []; + + for (const [categoryKey, currentTotal] of Object.entries( + currentMonthByCategory + )) { + const [categoryId, categoryName] = categoryKey.split("|"); + const previousTotal = previousMonthsByCategory[categoryKey] || 0; + const threeMonthAverage = previousTotal / 3; + + if (threeMonthAverage === 0) continue; + + const percentageIncrease = + ((currentTotal - threeMonthAverage) / threeMonthAverage) * 100; + + if (percentageIncrease > this.THRESHOLD_PERCENTAGE) { + categoryAnalysis.push({ + categoryId: categoryId ? Number(categoryId) : null, + categoryName: categoryName || "Sin categoría", + currentMonthTotal: currentTotal, + threeMonthAverage: threeMonthAverage, + percentageIncrease: Math.round(percentageIncrease), + }); + } + } + + if (categoryAnalysis.length === 0) { + return null; + } + + categoryAnalysis.sort( + (a, b) => b.percentageIncrease - a.percentageIncrease + ); + + return categoryAnalysis[0]; + } + + private groupByCategory( + transactions: any[] + ): Record { + return transactions.reduce((acc, tx) => { + const key = `${tx.categoryId || "null"}|${tx.category?.name || "Sin categoría"}`; + acc[key] = (acc[key] || 0) + tx.amount; + return acc; + }, {} as Record); + } + + private async getAIInsight( + spending: CategorySpending + ): Promise<{ insight: string; suggestion: string }> { + const prompt = `El usuario ha gastado $${spending.currentMonthTotal.toFixed(2)} en "${spending.categoryName}" este mes, lo cual es ${spending.percentageIncrease}% más que su promedio de 3 meses de $${spending.threeMonthAverage.toFixed(2)}. + +Proporciona: +1. Un análisis breve (2-3 oraciones) sobre este patrón de gasto +2. Una sugerencia accionable para el usuario + +Responde SOLO con un JSON válido en este formato: +{ + "insight": "análisis del patrón", + "suggestion": "sugerencia accionable" +}`; + + try { + const response = await fetch( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + Authorization: `Bearer ${this.OPENAI_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4", + messages: [ + { + role: "system", + content: + "Eres un asesor financiero experto. Proporciona análisis claros y sugerencias prácticas en español.", + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + max_tokens: 300, + response_format: { type: "json_object" }, + }), + } + ); + + if (!response.ok) { + throw new Error(`OpenAI API error: ${response.status}`); + } + + const result = await response.json(); + const content = result.choices[0]?.message?.content; + + if (!content) { + throw new Error("Empty response from OpenAI"); + } + + return JSON.parse(content); + } catch (error) { + console.error("Error calling OpenAI API:", error); + + return { + insight: `Has incrementado tu gasto en ${spending.categoryName} en un ${spending.percentageIncrease}% comparado con tu promedio mensual. Este cambio significativo merece atención.`, + suggestion: + "Revisa tus transacciones recientes en esta categoría para identificar gastos innecesarios y considera establecer un presupuesto mensual.", + }; + } + } + + private calculatePriority( + percentageIncrease: number + ): RecommendationPriority { + if (percentageIncrease >= 50) { + return RecommendationPriority.HIGH; + } else if (percentageIncrease >= 30) { + return RecommendationPriority.MEDIUM; + } + return RecommendationPriority.LOW; + } + + private getFirstDayOfCurrentMonth(): string { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), 1).toISOString(); + } +} diff --git a/src/features/recommendations/domain/entities/recommendation.entity.ts b/src/features/recommendations/domain/entities/recommendation.entity.ts new file mode 100644 index 0000000..5de0787 --- /dev/null +++ b/src/features/recommendations/domain/entities/recommendation.entity.ts @@ -0,0 +1,29 @@ +import { + RecommendationType, + RecommendationPriority, + RecommendationStatus, +} from "./recommendation.types"; + +export interface QuickAction { + label: string; + path: string; + prefilledData?: Record; +} + +export interface Recommendation { + id: number; + userId: number; + type: RecommendationType; + priority: RecommendationPriority; + title: string; + description: string; + data?: any; + actionable: boolean; + actions?: QuickAction[]; + status: RecommendationStatus; + createdAt: Date; + expiresAt?: Date; + viewedAt?: Date; + dismissedAt?: Date; + actedAt?: Date; +} diff --git a/src/features/recommendations/domain/entities/recommendation.types.ts b/src/features/recommendations/domain/entities/recommendation.types.ts new file mode 100644 index 0000000..867faa7 --- /dev/null +++ b/src/features/recommendations/domain/entities/recommendation.types.ts @@ -0,0 +1,19 @@ +export enum RecommendationType { + SPENDING_ANALYSIS = "SPENDING_ANALYSIS", + GOAL_OPTIMIZATION = "GOAL_OPTIMIZATION", + BUDGET_SUGGESTION = "BUDGET_SUGGESTION", + DEBT_REMINDER = "DEBT_REMINDER", +} + +export enum RecommendationPriority { + HIGH = "HIGH", + MEDIUM = "MEDIUM", + LOW = "LOW", +} + +export enum RecommendationStatus { + PENDING = "PENDING", + VIEWED = "VIEWED", + DISMISSED = "DISMISSED", + ACTED = "ACTED", +} diff --git a/src/features/recommendations/domain/ports/analysis.service.interface.ts b/src/features/recommendations/domain/ports/analysis.service.interface.ts new file mode 100644 index 0000000..d59fc03 --- /dev/null +++ b/src/features/recommendations/domain/ports/analysis.service.interface.ts @@ -0,0 +1,15 @@ +import { RecommendationPriority } from "../entities/recommendation.types"; +import { QuickAction } from "../entities/recommendation.entity"; + +export interface AnalysisResult { + shouldGenerate: boolean; + priority: RecommendationPriority; + title: string; + description: string; + data?: any; + actions?: QuickAction[]; +} + +export interface IAnalysisService { + analyze(userId: number): Promise; +} diff --git a/src/features/recommendations/domain/ports/orchestrator.service.interface.ts b/src/features/recommendations/domain/ports/orchestrator.service.interface.ts new file mode 100644 index 0000000..b583a61 --- /dev/null +++ b/src/features/recommendations/domain/ports/orchestrator.service.interface.ts @@ -0,0 +1,5 @@ +import { Recommendation } from "../entities/recommendation.entity"; + +export interface IRecommendationOrchestrator { + generateDailyRecommendation(userId: number): Promise; +} diff --git a/src/features/recommendations/domain/ports/recommendation.repository.interface.ts b/src/features/recommendations/domain/ports/recommendation.repository.interface.ts new file mode 100644 index 0000000..82b8ece --- /dev/null +++ b/src/features/recommendations/domain/ports/recommendation.repository.interface.ts @@ -0,0 +1,29 @@ +import { Recommendation } from "../entities/recommendation.entity"; +import { + RecommendationType, + RecommendationPriority, + RecommendationStatus, +} from "../entities/recommendation.types"; +import { QuickAction } from "../entities/recommendation.entity"; + +export interface CreateRecommendationData { + userId: number; + type: RecommendationType; + priority: RecommendationPriority; + title: string; + description: string; + data?: any; + actionable: boolean; + actions?: QuickAction[]; + expiresAt?: Date; +} + +export interface IRecommendationRepository { + create(data: CreateRecommendationData): Promise; + findById(id: number): Promise; + findPendingByUserId(userId: number): Promise; + markAsViewed(id: number): Promise; + markAsDismissed(id: number): Promise; + markAsActed(id: number): Promise; + deleteExpired(): Promise; +} diff --git a/src/features/recommendations/infrastructure/adapters/pg-recommendation.repository.ts b/src/features/recommendations/infrastructure/adapters/pg-recommendation.repository.ts new file mode 100644 index 0000000..58c9e26 --- /dev/null +++ b/src/features/recommendations/infrastructure/adapters/pg-recommendation.repository.ts @@ -0,0 +1,142 @@ +import { db } from "@/db"; +import { recommendations } from "@/schema"; +import { eq, and, gt, lt } from "drizzle-orm"; +import { IRecommendationRepository } from "../../domain/ports/recommendation.repository.interface"; +import { + Recommendation, + QuickAction, +} from "../../domain/entities/recommendation.entity"; +import { + RecommendationType, + RecommendationPriority, + RecommendationStatus, +} from "../../domain/entities/recommendation.types"; +import { CreateRecommendationData } from "../../domain/ports/recommendation.repository.interface"; + +export class PgRecommendationRepository implements IRecommendationRepository { + private static instance: PgRecommendationRepository; + + private constructor() {} + + public static getInstance(): PgRecommendationRepository { + if (!PgRecommendationRepository.instance) { + PgRecommendationRepository.instance = new PgRecommendationRepository(); + } + return PgRecommendationRepository.instance; + } + + async create(data: CreateRecommendationData): Promise { + const expiresAt = + data.expiresAt || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + const [recommendation] = await db + .insert(recommendations) + .values({ + user_id: data.userId, + type: data.type, + priority: data.priority, + title: data.title, + description: data.description, + data: data.data, + actionable: data.actionable, + actions: data.actions as any, + status: RecommendationStatus.PENDING, + expires_at: expiresAt, + }) + .returning(); + + return this.mapToEntity(recommendation); + } + + async findById(id: number): Promise { + const [recommendation] = await db + .select() + .from(recommendations) + .where(eq(recommendations.id, id)) + .limit(1); + + if (!recommendation) { + return null; + } + + return this.mapToEntity(recommendation); + } + + async findPendingByUserId(userId: number): Promise { + const now = new Date(); + + const results = await db + .select() + .from(recommendations) + .where( + and( + eq(recommendations.user_id, userId), + eq(recommendations.status, RecommendationStatus.PENDING), + gt(recommendations.expires_at, now) + ) + ) + .orderBy(recommendations.created_at); + + return results.map((rec) => this.mapToEntity(rec)); + } + + async markAsViewed(id: number): Promise { + await db + .update(recommendations) + .set({ + status: RecommendationStatus.VIEWED, + viewed_at: new Date(), + }) + .where(eq(recommendations.id, id)); + } + + async markAsDismissed(id: number): Promise { + await db + .update(recommendations) + .set({ + status: RecommendationStatus.DISMISSED, + dismissed_at: new Date(), + }) + .where(eq(recommendations.id, id)); + } + + async markAsActed(id: number): Promise { + await db + .update(recommendations) + .set({ + status: RecommendationStatus.ACTED, + acted_at: new Date(), + }) + .where(eq(recommendations.id, id)); + } + + async deleteExpired(): Promise { + const now = new Date(); + + const result = await db + .delete(recommendations) + .where(lt(recommendations.expires_at, now)); + + return result.rowCount || 0; + } + + private mapToEntity(dbRec: any): Recommendation { + return { + id: dbRec.id, + userId: dbRec.user_id, + type: dbRec.type as RecommendationType, + priority: dbRec.priority as RecommendationPriority, + title: dbRec.title, + description: dbRec.description, + data: dbRec.data, + actionable: dbRec.actionable, + actions: dbRec.actions as QuickAction[] | undefined, + status: dbRec.status as RecommendationStatus, + createdAt: dbRec.created_at, + expiresAt: dbRec.expires_at || undefined, + viewedAt: dbRec.viewed_at || undefined, + dismissedAt: dbRec.dismissed_at || undefined, + actedAt: dbRec.acted_at || undefined, + }; + } +} diff --git a/src/features/recommendations/infrastructure/clients/openai.client.ts b/src/features/recommendations/infrastructure/clients/openai.client.ts new file mode 100644 index 0000000..ce2e2d9 --- /dev/null +++ b/src/features/recommendations/infrastructure/clients/openai.client.ts @@ -0,0 +1,57 @@ +import OpenAI from "openai"; + +export class OpenAIClient { + private static instance: OpenAIClient; + private client: OpenAI; + + private constructor() { + this.client = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, + }); + } + + public static getInstance(): OpenAIClient { + if (!OpenAIClient.instance) { + OpenAIClient.instance = new OpenAIClient(); + } + return OpenAIClient.instance; + } + + async generateRecommendation(prompt: string): Promise { + try { + const response = await this.client.chat.completions.create( + { + model: "gpt-4", + messages: [ + { + role: "system", + content: + "You are a financial advisor assistant. Provide concise, actionable advice in Spanish. Always respond with valid JSON.", + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + response_format: { type: "json_object" }, + }, + { + timeout: 30000, + } + ); + + const content = response.choices[0].message.content; + if (!content) { + throw new Error("Empty response from OpenAI"); + } + + return JSON.parse(content); + } catch (error) { + console.error("Error calling OpenAI API:", error); + throw error; + } + } +} + +export const openAIClient = OpenAIClient.getInstance(); diff --git a/src/features/recommendations/infrastructure/controllers/recommendation.controller.ts b/src/features/recommendations/infrastructure/controllers/recommendation.controller.ts new file mode 100644 index 0000000..b69bb29 --- /dev/null +++ b/src/features/recommendations/infrastructure/controllers/recommendation.controller.ts @@ -0,0 +1,173 @@ +import { createRouter } from "@/core/infrastructure/lib/create-app"; +import * as routes from "./recommendation.routes"; +import { PgRecommendationRepository } from "../adapters/pg-recommendation.repository"; +import { toResponseDTO } from "../../application/dtos/recommendation.dto"; +import { Context } from "hono"; +import * as HttpStatusCodes from "stoker/http-status-codes"; + +const recommendationRepository = PgRecommendationRepository.getInstance(); + +const getPendingHandler = async (c: Context) => { + try { + const userId = c.req.query("userId")?.toString(); + + if (!userId) { + return c.json( + { + success: false, + data: null, + message: "User ID not found in context", + }, + HttpStatusCodes.UNAUTHORIZED + ); + } + + const recommendations = await recommendationRepository.findPendingByUserId( + Number(userId) + ); + + return c.json( + { + success: true, + data: recommendations.map(toResponseDTO), + message: "Recommendations retrieved successfully", + }, + HttpStatusCodes.OK + ); + } catch (error) { + console.error("Error getting pending recommendations:", error); + return c.json( + { + success: false, + data: null, + message: "Failed to retrieve recommendations", + }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ); + } +}; + +const markAsViewedHandler = async (c: Context) => { + try { + const { id } = c.req.param(); + + const recommendation = await recommendationRepository.findById(Number(id)); + if (!recommendation) { + return c.json( + { + success: false, + data: null, + message: "Recommendation not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + await recommendationRepository.markAsViewed(Number(id)); + + return c.json( + { + success: true, + data: { success: true }, + message: "Recommendation marked as viewed", + }, + HttpStatusCodes.OK + ); + } catch (error) { + console.error("Error marking recommendation as viewed:", error); + return c.json( + { + success: false, + data: null, + message: "Failed to mark recommendation as viewed", + }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ); + } +}; + +const markAsDismissedHandler = async (c: Context) => { + try { + const { id } = c.req.param(); + + const recommendation = await recommendationRepository.findById(Number(id)); + if (!recommendation) { + return c.json( + { + success: false, + data: null, + message: "Recommendation not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + await recommendationRepository.markAsDismissed(Number(id)); + + return c.json( + { + success: true, + data: { success: true }, + message: "Recommendation dismissed successfully", + }, + HttpStatusCodes.OK + ); + } catch (error) { + console.error("Error dismissing recommendation:", error); + return c.json( + { + success: false, + data: null, + message: "Failed to dismiss recommendation", + }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ); + } +}; + +const markAsActedHandler = async (c: Context) => { + try { + const { id } = c.req.param(); + + const recommendation = await recommendationRepository.findById(Number(id)); + if (!recommendation) { + return c.json( + { + success: false, + data: null, + message: "Recommendation not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + await recommendationRepository.markAsActed(Number(id)); + + return c.json( + { + success: true, + data: { success: true }, + message: "Recommendation marked as acted", + }, + HttpStatusCodes.OK + ); + } catch (error) { + console.error("Error marking recommendation as acted:", error); + return c.json( + { + success: false, + data: null, + message: "Failed to mark recommendation as acted", + }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ); + } +}; + +const router = createRouter() + .openapi(routes.getPending, getPendingHandler) + .openapi(routes.markAsViewed, markAsViewedHandler) + .openapi(routes.markAsDismissed, markAsDismissedHandler) + .openapi(routes.markAsActed, markAsActedHandler); + +export default router; diff --git a/src/features/recommendations/infrastructure/controllers/recommendation.routes.ts b/src/features/recommendations/infrastructure/controllers/recommendation.routes.ts new file mode 100644 index 0000000..adef321 --- /dev/null +++ b/src/features/recommendations/infrastructure/controllers/recommendation.routes.ts @@ -0,0 +1,191 @@ +import { createRoute } from "@hono/zod-openapi"; +import { z } from "zod"; +import * as HttpStatusCodes from "stoker/http-status-codes"; +import { + RecommendationType, + RecommendationPriority, + RecommendationStatus, +} from "../../domain/entities/recommendation.types"; + +const tags = ["Recommendations"]; + +const quickActionSchema = z.object({ + label: z.string(), + path: z.string(), + prefilledData: z.record(z.any()).optional(), +}); + +const recommendationResponseSchema = z.object({ + id: z.number(), + type: z.nativeEnum(RecommendationType), + priority: z.nativeEnum(RecommendationPriority), + title: z.string(), + description: z.string(), + data: z.any().optional(), + actionable: z.boolean(), + actions: z.array(quickActionSchema).optional(), + status: z.nativeEnum(RecommendationStatus), + createdAt: z.string(), + expiresAt: z.string().optional(), +}); + +const baseResponseSchema = (schema: T) => + z.object({ + success: z.boolean(), + data: schema, + message: z.string().optional(), + }); + +const errorResponseSchema = z.object({ + success: z.boolean(), + data: z.null(), + message: z.string(), +}); + +export const getPending = createRoute({ + path: "/recommendations", + method: "get", + tags, + responses: { + [HttpStatusCodes.OK]: { + content: { + "application/json": { + schema: baseResponseSchema(z.array(recommendationResponseSchema)), + }, + }, + description: "Pending recommendations retrieved successfully", + }, + [HttpStatusCodes.UNAUTHORIZED]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "User not authorized to access recommendations", + }, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Unexpected error retrieving recommendations", + }, + }, +}); + +export const markAsViewed = createRoute({ + path: "/recommendations/:id/view", + method: "patch", + tags, + request: { + params: z.object({ + id: z.string().regex(/^\d+$/).transform(Number), + }), + }, + responses: { + [HttpStatusCodes.OK]: { + content: { + "application/json": { + schema: baseResponseSchema(z.object({ success: z.boolean() })), + }, + }, + description: "Recommendation marked as viewed", + }, + [HttpStatusCodes.NOT_FOUND]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Recommendation not found", + }, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Unexpected error marking recommendation as viewed", + }, + }, +}); + +export const markAsDismissed = createRoute({ + path: "/recommendations/:id/dismiss", + method: "patch", + tags, + request: { + params: z.object({ + id: z.string().regex(/^\d+$/).transform(Number), + }), + }, + responses: { + [HttpStatusCodes.OK]: { + content: { + "application/json": { + schema: baseResponseSchema(z.object({ success: z.boolean() })), + }, + }, + description: "Recommendation dismissed successfully", + }, + [HttpStatusCodes.NOT_FOUND]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Recommendation not found", + }, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Unexpected error dismissing recommendation", + }, + }, +}); + +export const markAsActed = createRoute({ + path: "/recommendations/:id/act", + method: "patch", + tags, + request: { + params: z.object({ + id: z.string().regex(/^\d+$/).transform(Number), + }), + }, + responses: { + [HttpStatusCodes.OK]: { + content: { + "application/json": { + schema: baseResponseSchema(z.object({ success: z.boolean() })), + }, + }, + description: "Recommendation marked as acted", + }, + [HttpStatusCodes.NOT_FOUND]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Recommendation not found", + }, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Unexpected error marking recommendation as acted", + }, + }, +}); + +export type GetPendingRoute = typeof getPending; +export type MarkAsViewedRoute = typeof markAsViewed; +export type MarkAsDismissedRoute = typeof markAsDismissed; +export type MarkAsActedRoute = typeof markAsActed; diff --git a/src/features/reports/application/services/report.service.ts b/src/features/reports/application/services/report.service.ts index 08d6454..3cd9f6c 100644 --- a/src/features/reports/application/services/report.service.ts +++ b/src/features/reports/application/services/report.service.ts @@ -8,6 +8,11 @@ import { ContributionReport, SavingsComparisonReport, SavingsSummaryReport, + TransactionsSummaryReport, + ExpensesByCategoryReport, + MonthlyTrendReport, + BudgetPerformanceReport, + FinancialOverviewReport, } from "../../domain/entities/report.entity"; import { ReportService } from "../../domain/services/report.service"; import { ReportRepository } from "../../domain/repositories/report.repository"; @@ -18,9 +23,10 @@ import { PgGoalContributionRepository } from "../../../goals/infrastucture/adapt import { PgGoalRepository } from "../../../goals/infrastucture/adapters/goal.repository"; import { ExcelService } from "../../infrastructure/services/excel.service"; import { CSVService } from "../../infrastructure/services/csv.service"; -import { ICategory } from "../../../categories/domain/entities/ICategory"; import { IGoal } from "../../../goals/domain/entities/IGoal"; import { IGoalContribution } from "../../../goals/domain/entities/IGoalContribution"; +import { ITransaction } from "@/transactions/domain/entities/ITransaction"; +import { IBudget } from "@/budgets/domain/entities/IBudget"; export class ReportServiceImpl implements ReportService { private static instance: ReportServiceImpl; @@ -96,6 +102,21 @@ export class ReportServiceImpl implements ReportService { case ReportType.SAVINGS_SUMMARY: data = await this.generateSavingsSummaryReport(filters); break; + case ReportType.TRANSACTIONS_SUMMARY: + data = await this.generateTransactionsSummaryReport(filters); + break; + case ReportType.EXPENSES_BY_CATEGORY: + data = await this.generateExpensesByCategoryReport(filters); + break; + case ReportType.MONTHLY_TREND: + data = await this.generateMonthlyTrendReport(filters); + break; + case ReportType.BUDGET_PERFORMANCE: + data = await this.generateBudgetPerformanceReport(filters); + break; + case ReportType.FINANCIAL_OVERVIEW: + data = await this.generateFinancialOverviewReport(filters); + break; default: throw new Error(`Unsupported report type: ${type}`); } @@ -130,6 +151,507 @@ export class ReportServiceImpl implements ReportService { await this.reportRepository.deleteExpired(); } + private async generateTransactionsSummaryReport( + filters: ReportFilters + ): Promise { + if (!filters.userId) { + throw new Error("User ID is required for transactions summary report"); + } + + const transactions = await this.transactionRepository.findByFilters( + Number(filters.userId), + { + startDate: filters.startDate?.toISOString(), + endDate: filters.endDate?.toISOString(), + type: undefined, + } + ); + + if (transactions.length === 0) { + return { + totalIncome: 0, + totalExpense: 0, + netBalance: 0, + transactionCount: 0, + incomeCount: 0, + expenseCount: 0, + averageIncome: 0, + averageExpense: 0, + transactions: [], + }; + } + + const incomeTransactions = transactions.filter( + (t: ITransaction) => t.type === "INCOME" + ); + const expenseTransactions = transactions.filter( + (t: ITransaction) => t.type === "EXPENSE" + ); + + const totalIncome = incomeTransactions.reduce( + (sum: number, t: ITransaction) => sum + (t.amount || 0), + 0 + ); + const totalExpense = expenseTransactions.reduce( + (sum: number, t: ITransaction) => sum + (t.amount || 0), + 0 + ); + + const categoryTotals = new Map< + string, + { name: string; amount: number; type: string } + >(); + transactions.forEach((t: ITransaction) => { + const categoryName = t.category?.name || "Sin categoría"; + const key = `${t.type}-${categoryName}`; + + if (!categoryTotals.has(key)) { + categoryTotals.set(key, { + name: categoryName, + amount: 0, + type: t.type, + }); + } + + const current = categoryTotals.get(key)!; + current.amount += t.amount || 0; + }); + + const topIncome = Array.from(categoryTotals.values()) + .filter((c) => c.type === "INCOME") + .sort((a, b) => b.amount - a.amount)[0]; + + const topExpense = Array.from(categoryTotals.values()) + .filter((c) => c.type === "EXPENSE") + .sort((a, b) => b.amount - a.amount)[0]; + + return { + totalIncome: Math.round(totalIncome * 100) / 100, + totalExpense: Math.round(totalExpense * 100) / 100, + netBalance: Math.round((totalIncome - totalExpense) * 100) / 100, + transactionCount: transactions.length, + incomeCount: incomeTransactions.length, + expenseCount: expenseTransactions.length, + averageIncome: + incomeTransactions.length > 0 + ? Math.round((totalIncome / incomeTransactions.length) * 100) / 100 + : 0, + averageExpense: + expenseTransactions.length > 0 + ? Math.round((totalExpense / expenseTransactions.length) * 100) / 100 + : 0, + topIncomeCategory: topIncome + ? { name: topIncome.name, amount: topIncome.amount } + : undefined, + topExpenseCategory: topExpense + ? { name: topExpense.name, amount: topExpense.amount } + : undefined, + transactions: transactions.map((t: ITransaction) => ({ + id: t.id?.toString() || "", + type: t.type as "INCOME" | "EXPENSE", + amount: t.amount || 0, + category: t.category?.name, + description: t.description || undefined, + date: t.date || new Date(), + })), + }; + } + + private async generateExpensesByCategoryReport( + filters: ReportFilters + ): Promise { + if (!filters.userId) { + throw new Error("User ID is required for expenses by category report"); + } + + const transactions = await this.transactionRepository.findByFilters( + Number(filters.userId), + { + startDate: filters.startDate?.toISOString(), + endDate: filters.endDate?.toISOString(), + type: "EXPENSE", + } + ); + + if (transactions.length === 0) { + return { + totalExpenses: 0, + categoryCount: 0, + categories: [], + }; + } + + const totalExpenses = transactions.reduce( + (sum: number, t: ITransaction) => sum + (t.amount || 0), + 0 + ); + + const categoryMap = new Map< + string, + { id: string; name: string; transactions: ITransaction[] } + >(); + + transactions.forEach((t: ITransaction) => { + const categoryId = t.categoryId?.toString() || "sin-categoria"; + const categoryName = t.category?.name || "Sin categoría"; + + if (!categoryMap.has(categoryId)) { + categoryMap.set(categoryId, { + id: categoryId, + name: categoryName, + transactions: [], + }); + } + + categoryMap.get(categoryId)!.transactions.push(t); + }); + + const categories = Array.from(categoryMap.values()) + .map((category) => { + const amount = category.transactions.reduce( + (sum: number, t: ITransaction) => sum + (t.amount || 0), + 0 + ); + const percentage = + totalExpenses > 0 + ? Math.round((amount / totalExpenses) * 100 * 100) / 100 + : 0; + + return { + id: category.id, + name: category.name, + amount: Math.round(amount * 100) / 100, + percentage, + transactionCount: category.transactions.length, + transactions: category.transactions.map((t: ITransaction) => ({ + id: t.id?.toString() || "", + amount: t.amount || 0, + description: t.description || undefined, + date: t.date || new Date(), + })), + }; + }) + .sort((a, b) => b.amount - a.amount); + + return { + totalExpenses: Math.round(totalExpenses * 100) / 100, + categoryCount: categories.length, + categories, + }; + } + + private async generateMonthlyTrendReport( + filters: ReportFilters + ): Promise { + if (!filters.userId) { + throw new Error("User ID is required for monthly trend report"); + } + + const transactions = await this.transactionRepository.findByFilters( + Number(filters.userId), + { + startDate: filters.startDate?.toISOString(), + endDate: filters.endDate?.toISOString(), + type: undefined, + } + ); + + if (transactions.length === 0) { + return { + months: [], + averageMonthlyIncome: 0, + averageMonthlyExpense: 0, + trend: "stable", + }; + } + + const monthlyData = new Map< + string, + { income: number; expense: number; count: number } + >(); + + transactions.forEach((t: ITransaction) => { + const date = new Date(t.date); + const monthKey = `${date.getFullYear()}-${String( + date.getMonth() + 1 + ).padStart(2, "0")}`; + + if (!monthlyData.has(monthKey)) { + monthlyData.set(monthKey, { income: 0, expense: 0, count: 0 }); + } + + const data = monthlyData.get(monthKey)!; + data.count++; + + if (t.type === "INCOME") { + data.income += t.amount || 0; + } else { + data.expense += t.amount || 0; + } + }); + + const months = Array.from(monthlyData.entries()) + .map(([month, data]) => ({ + month, + income: Math.round(data.income * 100) / 100, + expense: Math.round(data.expense * 100) / 100, + balance: Math.round((data.income - data.expense) * 100) / 100, + transactionCount: data.count, + })) + .sort((a, b) => a.month.localeCompare(b.month)); + + const totalIncome = months.reduce((sum, m) => sum + m.income, 0); + const totalExpense = months.reduce((sum, m) => sum + m.expense, 0); + const averageMonthlyIncome = + months.length > 0 + ? Math.round((totalIncome / months.length) * 100) / 100 + : 0; + const averageMonthlyExpense = + months.length > 0 + ? Math.round((totalExpense / months.length) * 100) / 100 + : 0; + + let trend: "increasing" | "decreasing" | "stable" = "stable"; + if (months.length >= 2) { + const firstHalf = months.slice(0, Math.floor(months.length / 2)); + const secondHalf = months.slice(Math.floor(months.length / 2)); + + const firstHalfBalance = + firstHalf.reduce((sum, m) => sum + m.balance, 0) / firstHalf.length; + const secondHalfBalance = + secondHalf.reduce((sum, m) => sum + m.balance, 0) / secondHalf.length; + + if (secondHalfBalance > firstHalfBalance * 1.1) trend = "increasing"; + else if (secondHalfBalance < firstHalfBalance * 0.9) trend = "decreasing"; + } + + return { + months, + averageMonthlyIncome, + averageMonthlyExpense, + trend, + }; + } + + private async generateBudgetPerformanceReport( + filters: ReportFilters + ): Promise { + if (!filters.userId) { + throw new Error("User ID is required for budget performance report"); + } + + const budgets = await this.budgetRepository.findByUserId( + Number(filters.userId) + ); + + if (budgets.length === 0) { + return { + totalBudgets: 0, + exceededCount: 0, + warningCount: 0, + goodCount: 0, + budgets: [], + }; + } + + let exceededCount = 0; + let warningCount = 0; + let goodCount = 0; + + const budgetDetails = budgets.map((budget: IBudget) => { + const limitAmount = budget.limitAmount || 0; + const currentAmount = budget.currentAmount || 0; + const percentage = + limitAmount > 0 + ? Math.round((currentAmount / limitAmount) * 100 * 100) / 100 + : 0; + + let status: "exceeded" | "warning" | "good"; + if (percentage >= 100) { + status = "exceeded"; + exceededCount++; + } else if (percentage >= 80) { + status = "warning"; + warningCount++; + } else { + status = "good"; + goodCount++; + } + + const startDate = budget.month ? new Date(budget.month) : new Date(); + const monthKey = `${startDate.getFullYear()}-${String( + startDate.getMonth() + 1 + ).padStart(2, "0")}`; + + return { + id: budget.id?.toString() || "", + categoryName: budget.category?.name || "Sin categoría", + limitAmount: Math.round(limitAmount * 100) / 100, + currentAmount: Math.round(currentAmount * 100) / 100, + percentage, + status, + month: monthKey, + }; + }); + + return { + totalBudgets: budgets.length, + exceededCount, + warningCount, + goodCount, + budgets: budgetDetails, + }; + } + + private async generateFinancialOverviewReport( + filters: ReportFilters + ): Promise { + if (!filters.userId) { + throw new Error("User ID is required for financial overview report"); + } + + const startDate = + filters.startDate || new Date(new Date().getFullYear(), 0, 1); + const endDate = filters.endDate || new Date(); + + const [transactions, goals, budgets] = await Promise.all([ + this.transactionRepository.findByFilters(Number(filters.userId), { + startDate: filters.startDate?.toISOString(), + endDate: filters.endDate?.toISOString(), + type: undefined, + }), + this.goalRepository.findByFilters({ userId: filters.userId }), + this.budgetRepository.findByUserId(Number(filters.userId)), + ]); + + const totalIncome = transactions + .filter((t: ITransaction) => t.type === "INCOME") + .reduce((sum: number, t: ITransaction) => sum + (t.amount || 0), 0); + + const totalExpense = transactions + .filter((t: ITransaction) => t.type === "EXPENSE") + .reduce((sum: number, t: ITransaction) => sum + (t.amount || 0), 0); + + const netBalance = totalIncome - totalExpense; + const savingsRate = + totalIncome > 0 + ? Math.round((netBalance / totalIncome) * 100 * 100) / 100 + : 0; + + const now = new Date(); + const completedGoals = goals.filter( + (g: IGoal) => (g.currentAmount || 0) >= (g.targetAmount || 0) + ).length; + const inProgressGoals = goals.filter( + (g: IGoal) => + (g.currentAmount || 0) < (g.targetAmount || 0) && g.endDate >= now + ).length; + const totalSaved = goals.reduce( + (sum: number, g: IGoal) => sum + (g.currentAmount || 0), + 0 + ); + const totalTarget = goals.reduce( + (sum: number, g: IGoal) => sum + (g.targetAmount || 0), + 0 + ); + const overallProgress = + totalTarget > 0 + ? Math.round((totalSaved / totalTarget) * 100 * 100) / 100 + : 0; + + const exceededBudgets = budgets.filter( + (b: IBudget) => (b.currentAmount || 0) >= (b.limitAmount || 0) + ).length; + const totalUtilization = budgets.reduce((sum: number, b: IBudget) => { + const limit = b.limitAmount || 0; + const current = b.currentAmount || 0; + return sum + (limit > 0 ? (current / limit) * 100 : 0); + }, 0); + const averageUtilization = + budgets.length > 0 + ? Math.round((totalUtilization / budgets.length) * 100) / 100 + : 0; + + const expenseCategoryTotals = new Map(); + const incomeCategoryTotals = new Map(); + + transactions.forEach((t: ITransaction) => { + const categoryName = t.category?.name || "Sin categoría"; + const amount = t.amount || 0; + + if (t.type === "EXPENSE") { + expenseCategoryTotals.set( + categoryName, + (expenseCategoryTotals.get(categoryName) || 0) + amount + ); + } else { + incomeCategoryTotals.set( + categoryName, + (incomeCategoryTotals.get(categoryName) || 0) + amount + ); + } + }); + + const topExpenses = Array.from(expenseCategoryTotals.entries()) + .map(([name, amount]) => ({ + name, + amount: Math.round(amount * 100) / 100, + percentage: + totalExpense > 0 + ? Math.round((amount / totalExpense) * 100 * 100) / 100 + : 0, + })) + .sort((a, b) => b.amount - a.amount) + .slice(0, 5); + + const topIncome = Array.from(incomeCategoryTotals.entries()) + .map(([name, amount]) => ({ + name, + amount: Math.round(amount * 100) / 100, + percentage: + totalIncome > 0 + ? Math.round((amount / totalIncome) * 100 * 100) / 100 + : 0, + })) + .sort((a, b) => b.amount - a.amount) + .slice(0, 5); + + return { + period: { + startDate, + endDate, + }, + summary: { + totalIncome: Math.round(totalIncome * 100) / 100, + totalExpense: Math.round(totalExpense * 100) / 100, + netBalance: Math.round(netBalance * 100) / 100, + savingsRate, + }, + goals: { + total: goals.length, + completed: completedGoals, + inProgress: inProgressGoals, + totalSaved: Math.round(totalSaved * 100) / 100, + totalTarget: Math.round(totalTarget * 100) / 100, + overallProgress, + }, + budgets: { + total: budgets.length, + exceeded: exceededBudgets, + averageUtilization, + }, + debts: { + total: 0, + totalAmount: 0, + totalPending: 0, + }, + topCategories: { + expenses: topExpenses, + income: topIncome, + }, + }; + } + private async generateGoalsByStatusReport( filters: ReportFilters ): Promise { @@ -141,7 +663,7 @@ export class ReportServiceImpl implements ReportService { const now = new Date(); if (goals.length === 0) { - console.log("No goals found"); + console.log("No goals found for user:", filters.userId); return { completed: 0, @@ -156,76 +678,127 @@ export class ReportServiceImpl implements ReportService { let expiredGoals = 0; let completedGoals = 0; - const report: GoalStatusReport = { - completed: completedGoals, - expired: expiredGoals, - inProgress: inProgressGoals, - total: goals.length, - goals: goals.map((goal: IGoal) => { - const status = this.determineGoalStatus(goal, now); - if (status === "completed") completedGoals++; - else if (status === "expired") expiredGoals++; - else inProgressGoals++; + const goalsWithStatus = goals.map((goal: IGoal) => { + const status = this.determineGoalStatus(goal, now); - return { - id: goal.id?.toString() || "", - name: goal.name, - status, - targetAmount: goal.targetAmount, - currentAmount: goal.currentAmount, - progress: (goal.currentAmount / goal.targetAmount) * 100, - deadline: goal.endDate, - }; - }), - }; + if (status === "completed") completedGoals++; + else if (status === "expired") expiredGoals++; + else inProgressGoals++; + + // Validaciones null-safe + const targetAmount = goal.targetAmount || 0; + const currentAmount = goal.currentAmount || 0; + const progress = + targetAmount > 0 + ? Math.round((currentAmount / targetAmount) * 100 * 100) / 100 + : 0; + + return { + id: goal.id?.toString() || "", + name: goal.name || "Sin nombre", + status, + targetAmount, + currentAmount, + progress, + deadline: goal.endDate || new Date(), + categoryName: goal.category?.name || "Sin categoría", // ADDED + }; + }); return { - ...report, completed: completedGoals, expired: expiredGoals, inProgress: inProgressGoals, + total: goals.length, + goals: goalsWithStatus, }; } private async generateGoalsByCategoryReport( filters: ReportFilters ): Promise { + if (!filters.userId) { + throw new Error("User ID is required for goals by category report"); + } + const goals = await this.goalRepository.findByFilters(filters); - const categories = await this.goalRepository.findAll(); - const report: GoalCategoryReport = { - categories: categories.map((category: ICategory) => { - const categoryGoals = goals.filter( - (goal: IGoal) => goal.categoryId === category.id - ); + if (goals.length === 0) { + console.log("No goals found for user:", filters.userId); + + return { + totalCategories: 0, + totalGoals: 0, + categories: [], + }; + } + + // Agrupar metas por categoría + const goalsByCategory = new Map(); + + goals.forEach((goal: IGoal) => { + const categoryId = goal.categoryId?.toString() || "sin-categoria"; + const categoryName = goal.category?.name || "Sin categoría"; + const key = `${categoryId}|${categoryName}`; + + if (!goalsByCategory.has(key)) { + goalsByCategory.set(key, []); + } + goalsByCategory.get(key)!.push(goal); + }); + + const now = new Date(); + const categories = Array.from(goalsByCategory.entries()).map( + ([key, categoryGoals]) => { + const [categoryId, categoryName] = key.split("|"); + const totalAmount = categoryGoals.reduce( - (sum: number, goal: IGoal) => sum + goal.targetAmount, + (sum: number, goal: IGoal) => sum + (goal.targetAmount || 0), 0 ); const completedAmount = categoryGoals.reduce( - (sum: number, goal: IGoal) => sum + goal.currentAmount, + (sum: number, goal: IGoal) => sum + (goal.currentAmount || 0), 0 ); + const progress = + totalAmount > 0 + ? Math.round((completedAmount / totalAmount) * 100 * 100) / 100 + : 0; return { - id: category.id?.toString() || "", - name: category.name, + id: categoryId, + name: categoryName, totalGoals: categoryGoals.length, totalAmount, completedAmount, - progress: (completedAmount / totalAmount) * 100, - goals: categoryGoals.map((goal: IGoal) => ({ - id: goal.id?.toString() || "", - name: goal.name, - targetAmount: goal.targetAmount, - currentAmount: goal.currentAmount, - progress: (goal.currentAmount / goal.targetAmount) * 100, - })), + progress, + goals: categoryGoals.map((goal: IGoal) => { + const targetAmount = goal.targetAmount || 0; + const currentAmount = goal.currentAmount || 0; + const goalProgress = + targetAmount > 0 + ? Math.round((currentAmount / targetAmount) * 100 * 100) / 100 + : 0; + + return { + id: goal.id?.toString() || "", + name: goal.name || "Sin nombre", + targetAmount, + currentAmount, + progress: goalProgress, + endDate: goal.endDate || new Date(), + status: this.determineGoalStatus(goal, now), + }; + }), }; - }), - }; + } + ); - return report; + return { + totalCategories: categories.length, + totalGoals: goals.length, + categories, + }; } private async generateContributionsByGoalReport( @@ -244,25 +817,24 @@ export class ReportServiceImpl implements ReportService { Number(filters.goalId) ); + const totalContributions = contributions.reduce( + (sum: number, c: IGoalContribution) => sum + (c.amount || 0), + 0 + ); + const report: ContributionReport = { goalId: goal.id?.toString() || "", - goalName: goal.name, + goalName: goal.name || "Sin nombre", contributions: contributions.map((contribution: IGoalContribution) => ({ id: contribution.id?.toString() || "", - amount: contribution.amount, - date: contribution.date, - transactionId: contribution.transactionId, + amount: contribution.amount || 0, + date: contribution.date || new Date(), + transactionId: contribution?.id?.toString(), })), - totalContributions: contributions.reduce( - (sum: number, c: IGoalContribution) => sum + c.amount, - 0 - ), + totalContributions, averageContribution: contributions.length > 0 - ? contributions.reduce( - (sum: number, c: IGoalContribution) => sum + c.amount, - 0 - ) / contributions.length + ? Math.round((totalContributions / contributions.length) * 100) / 100 : 0, lastContributionDate: contributions.length > 0 @@ -294,7 +866,7 @@ export class ReportServiceImpl implements ReportService { const report: SavingsComparisonReport = { goalId: goal.id?.toString() || "", - goalName: goal.name, + goalName: goal.name || "Sin nombre", plannedSavings, actualSavings, deviations: plannedSavings.map((planned, index) => ({ @@ -311,73 +883,115 @@ export class ReportServiceImpl implements ReportService { private async generateSavingsSummaryReport( filters: ReportFilters ): Promise { + if (!filters.userId) { + throw new Error("User ID is required for savings summary report"); + } + const goals = await this.goalRepository.findByFilters(filters); - const categories = await this.goalRepository.findAll(); const contributions = await this.goalContributionRepository.findByFilters( filters ); + if (goals.length === 0) { + return { + totalGoals: 0, + totalTargetAmount: 0, + totalCurrentAmount: 0, + overallProgress: 0, + completedGoals: 0, + expiredGoals: 0, + inProgressGoals: 0, + averageContribution: 0, + lastContributionDate: undefined, + categoryBreakdown: [], + }; + } + const now = new Date(); const completedGoals = goals.filter( - (g: IGoal) => g.currentAmount >= g.targetAmount + (g: IGoal) => (g.currentAmount || 0) >= (g.targetAmount || 0) ).length; const expiredGoals = goals.filter( - (g: IGoal) => g.endDate < now && g.currentAmount < g.targetAmount + (g: IGoal) => + g.endDate < now && (g.currentAmount || 0) < (g.targetAmount || 0) ).length; const inProgressGoals = goals.length - completedGoals - expiredGoals; + const totalTargetAmount = goals.reduce( + (sum: number, g: IGoal) => sum + (g.targetAmount || 0), + 0 + ); + const totalCurrentAmount = goals.reduce( + (sum: number, g: IGoal) => sum + (g.currentAmount || 0), + 0 + ); + + const overallProgress = + totalTargetAmount > 0 + ? Math.round((totalCurrentAmount / totalTargetAmount) * 100 * 100) / 100 + : 0; + + const totalContributions = contributions.reduce( + (sum: number, c: IGoalContribution) => sum + (c.amount || 0), + 0 + ); + + // Agrupar por categoría + const goalsByCategory = new Map(); + goals.forEach((goal: IGoal) => { + const categoryId = goal.categoryId?.toString() || "sin-categoria"; + const categoryName = goal.category?.name || "Sin categoría"; + const key = `${categoryId}|${categoryName}`; + + if (!goalsByCategory.has(key)) { + goalsByCategory.set(key, []); + } + goalsByCategory.get(key)!.push(goal); + }); + + const categoryBreakdown = Array.from(goalsByCategory.entries()).map( + ([key, categoryGoals]) => { + const [categoryId, categoryName] = key.split("|"); + const totalAmount = categoryGoals.reduce( + (sum: number, g: IGoal) => sum + (g.targetAmount || 0), + 0 + ); + const currentAmount = categoryGoals.reduce( + (sum: number, g: IGoal) => sum + (g.currentAmount || 0), + 0 + ); + const progress = + totalAmount > 0 + ? Math.round((currentAmount / totalAmount) * 100 * 100) / 100 + : 0; + + return { + categoryId, + categoryName, + totalGoals: categoryGoals.length, + totalAmount, + progress, + }; + } + ); + const report: SavingsSummaryReport = { totalGoals: goals.length, - totalTargetAmount: goals.reduce( - (sum: number, g: IGoal) => sum + g.targetAmount, - 0 - ), - totalCurrentAmount: goals.reduce( - (sum: number, g: IGoal) => sum + g.currentAmount, - 0 - ), - overallProgress: - (goals.reduce( - (sum: number, g: IGoal) => sum + g.currentAmount / g.targetAmount, - 0 - ) / - goals.length) * - 100, + totalTargetAmount, + totalCurrentAmount, + overallProgress, completedGoals, expiredGoals, inProgressGoals, averageContribution: contributions.length > 0 - ? contributions.reduce( - (sum: number, c: IGoalContribution) => sum + c.amount, - 0 - ) / contributions.length + ? Math.round((totalContributions / contributions.length) * 100) / 100 : 0, lastContributionDate: contributions.length > 0 ? contributions[contributions.length - 1].date : undefined, - categoryBreakdown: categories.map((category: ICategory) => { - const categoryGoals = goals.filter( - (g: IGoal) => g.categoryId === category.id - ); - const totalAmount = categoryGoals.reduce( - (sum: number, g: IGoal) => sum + g.targetAmount, - 0 - ); - const currentAmount = categoryGoals.reduce( - (sum: number, g: IGoal) => sum + g.currentAmount, - 0 - ); - - return { - categoryId: category.id?.toString() || "", - categoryName: category.name, - totalGoals: categoryGoals.length, - totalAmount, - progress: (currentAmount / totalAmount) * 100, - }; - }), + categoryBreakdown, }; return report; @@ -387,7 +1001,10 @@ export class ReportServiceImpl implements ReportService { goal: IGoal, now: Date ): "completed" | "expired" | "inProgress" { - if (goal.currentAmount >= goal.targetAmount) return "completed"; + const currentAmount = goal.currentAmount || 0; + const targetAmount = goal.targetAmount || 0; + + if (currentAmount >= targetAmount) return "completed"; if (goal.endDate < now) return "expired"; return "inProgress"; } @@ -402,8 +1019,9 @@ export class ReportServiceImpl implements ReportService { const plannedSavings = []; let currentDate = new Date(startDate); + const monthsBetween = this.monthsBetween(startDate, endDate); const monthlyTarget = - goal.targetAmount / this.monthsBetween(startDate, endDate); + monthsBetween > 0 ? (goal.targetAmount || 0) / monthsBetween : 0; while (currentDate <= endDate) { plannedSavings.push({ @@ -444,7 +1062,7 @@ export class ReportServiceImpl implements ReportService { } const currentAmount = groupedContributions.get(key) || 0; - groupedContributions.set(key, currentAmount + contribution.amount); + groupedContributions.set(key, currentAmount + (contribution.amount || 0)); }); return Array.from(groupedContributions.entries()) @@ -459,11 +1077,11 @@ export class ReportServiceImpl implements ReportService { } private monthsBetween(startDate: Date, endDate: Date): number { - return ( + const months = (endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + - 1 - ); + 1; + return months > 0 ? months : 1; // Evitar división por 0 } private generateReportId(): string { diff --git a/src/features/reports/domain/entities/report.entity.ts b/src/features/reports/domain/entities/report.entity.ts index cfc31cb..0e5d6af 100644 --- a/src/features/reports/domain/entities/report.entity.ts +++ b/src/features/reports/domain/entities/report.entity.ts @@ -20,6 +20,11 @@ export enum ReportType { CONTRIBUTIONS_BY_GOAL = "CONTRIBUTIONS_BY_GOAL", SAVINGS_COMPARISON = "SAVINGS_COMPARISON", SAVINGS_SUMMARY = "SAVINGS_SUMMARY", + TRANSACTIONS_SUMMARY = "TRANSACTIONS_SUMMARY", + EXPENSES_BY_CATEGORY = "EXPENSES_BY_CATEGORY", + MONTHLY_TREND = "MONTHLY_TREND", + BUDGET_PERFORMANCE = "BUDGET_PERFORMANCE", + FINANCIAL_OVERVIEW = "FINANCIAL_OVERVIEW", } export enum ReportFormat { @@ -37,6 +42,7 @@ export interface ReportFilters { goalId?: string; includeShared?: boolean; groupBy?: "day" | "week" | "month"; + filterBy?: "created_at" | "end_date"; [key: string]: any; } @@ -53,10 +59,13 @@ export interface GoalStatusReport { currentAmount: number; progress: number; deadline: Date; + categoryName?: string; }>; } export interface GoalCategoryReport { + totalCategories: number; + totalGoals: number; categories: Array<{ id: string; name: string; @@ -70,6 +79,8 @@ export interface GoalCategoryReport { targetAmount: number; currentAmount: number; progress: number; + endDate: Date; + status: "completed" | "expired" | "inProgress"; }>; }>; } @@ -125,3 +136,120 @@ export interface SavingsSummaryReport { progress: number; }>; } + +export interface TransactionsSummaryReport { + totalIncome: number; + totalExpense: number; + netBalance: number; + transactionCount: number; + incomeCount: number; + expenseCount: number; + averageIncome: number; + averageExpense: number; + topIncomeCategory?: { + name: string; + amount: number; + }; + topExpenseCategory?: { + name: string; + amount: number; + }; + transactions: Array<{ + id: string; + type: "INCOME" | "EXPENSE"; + amount: number; + category?: string; + description?: string; + date: Date; + }>; +} + +export interface ExpensesByCategoryReport { + totalExpenses: number; + categoryCount: number; + categories: Array<{ + id: string; + name: string; + amount: number; + percentage: number; + transactionCount: number; + transactions: Array<{ + id: string; + amount: number; + description?: string; + date: Date; + }>; + }>; +} + +export interface MonthlyTrendReport { + months: Array<{ + month: string; + income: number; + expense: number; + balance: number; + transactionCount: number; + }>; + averageMonthlyIncome: number; + averageMonthlyExpense: number; + trend: "increasing" | "decreasing" | "stable"; +} + +export interface BudgetPerformanceReport { + totalBudgets: number; + exceededCount: number; + warningCount: number; + goodCount: number; + budgets: Array<{ + id: string; + categoryName: string; + limitAmount: number; + currentAmount: number; + percentage: number; + status: "exceeded" | "warning" | "good"; + month: string; + }>; +} + +export interface FinancialOverviewReport { + period: { + startDate: Date; + endDate: Date; + }; + summary: { + totalIncome: number; + totalExpense: number; + netBalance: number; + savingsRate: number; + }; + goals: { + total: number; + completed: number; + inProgress: number; + totalSaved: number; + totalTarget: number; + overallProgress: number; + }; + budgets: { + total: number; + exceeded: number; + averageUtilization: number; + }; + debts: { + total: number; + totalAmount: number; + totalPending: number; + }; + topCategories: { + expenses: Array<{ + name: string; + amount: number; + percentage: number; + }>; + income: Array<{ + name: string; + amount: number; + percentage: number; + }>; + }; +} diff --git a/src/features/reports/infrastructure/controllers/report.controller.ts b/src/features/reports/infrastructure/controllers/report.controller.ts index f4f3dcc..847c56d 100644 --- a/src/features/reports/infrastructure/controllers/report.controller.ts +++ b/src/features/reports/infrastructure/controllers/report.controller.ts @@ -41,7 +41,38 @@ const generateReportHandler = createHandler(async (c: Context) => { try { const { type, format, filters } = await c.req.json(); - const report = await reportService.generateReport(type, format, filters); + // FIXED: Convertir strings de fecha a Date objects + const processedFilters = { + ...filters, + startDate: filters.startDate ? new Date(filters.startDate) : undefined, + endDate: filters.endDate ? new Date(filters.endDate) : undefined, + // ADDED: Para reportes, siempre usar 'created_at' para filtrar metas + filterBy: "created_at" as const, + }; + + // FIXED: Validar que goalId sea proporcionado cuando se requiere + const requiresGoalId = [ + ReportType.CONTRIBUTIONS_BY_GOAL, + ReportType.SAVINGS_COMPARISON, + ]; + + if (requiresGoalId.includes(type) && !processedFilters.goalId) { + return c.json( + { + success: false, + data: null, + message: "Se requiere seleccionar una meta para este tipo de reporte", + }, + HttpStatusCodes.BAD_REQUEST + ); + } + + const report = await reportService.generateReport( + type, + format, + processedFilters + ); + return c.json( { success: true, @@ -57,7 +88,7 @@ const generateReportHandler = createHandler(async (c: Context) => { HttpStatusCodes.OK ); } catch (error) { - console.error(error); + console.error("Error generating report:", error); return c.json( { @@ -79,7 +110,7 @@ const getReportHandler = createHandler(async (c: Context) => { if (report.format === ReportFormat.PDF) { const pdfBuffer = await pdfService.generatePDF(report); - return new Response(pdfBuffer, { + return new Response(new Uint8Array(pdfBuffer), { status: HttpStatusCodes.OK, headers: { "Content-Type": "application/pdf", @@ -90,7 +121,7 @@ const getReportHandler = createHandler(async (c: Context) => { if (report.format === ReportFormat.EXCEL) { const excelBuffer = await excelService.generateExcel(report); - return new Response(excelBuffer, { + return new Response(new Uint8Array(excelBuffer), { status: HttpStatusCodes.OK, headers: { "Content-Type": @@ -102,7 +133,7 @@ const getReportHandler = createHandler(async (c: Context) => { if (report.format === ReportFormat.CSV) { const csvBuffer = await csvService.generateCSV(report); - return new Response(csvBuffer, { + return new Response(new TextEncoder().encode(csvBuffer), { status: HttpStatusCodes.OK, headers: { "Content-Type": "text/csv", @@ -111,6 +142,7 @@ const getReportHandler = createHandler(async (c: Context) => { }); } + // FIXED: Manejar correctamente las fechas en el response JSON return c.json( { success: true, @@ -119,14 +151,22 @@ const getReportHandler = createHandler(async (c: Context) => { type: report.type, format: report.format, data: report.data, - createdAt: report.createdAt?.toISOString(), - expiresAt: report.expiresAt?.toISOString(), + createdAt: + typeof report.createdAt === "string" + ? report.createdAt + : report.createdAt?.toISOString(), + expiresAt: + typeof report.expiresAt === "string" + ? report.expiresAt + : report.expiresAt?.toISOString(), }, message: "Report retrieved successfully", }, HttpStatusCodes.OK ); } catch (error) { + console.error("Error retrieving report:", error); + return c.json( { success: false, diff --git a/src/features/reports/infrastructure/services/csv.service.ts b/src/features/reports/infrastructure/services/csv.service.ts index 01cbb1c..af7ff66 100644 --- a/src/features/reports/infrastructure/services/csv.service.ts +++ b/src/features/reports/infrastructure/services/csv.service.ts @@ -1,5 +1,19 @@ import { Report, ReportType } from "../../domain/entities/report.entity"; import { Parser } from "json2csv"; +import { + prepareGoalsByStatusData, + prepareGoalsByCategoryData, + prepareContributionsByGoalData, + prepareSavingsComparisonData, + prepareSavingsSummaryData, +} from "./csv/csv-goal-formatters"; +import { + prepareTransactionsSummaryData, + prepareExpensesByCategoryData, + prepareMonthlyTrendData, +} from "./csv/csv-transaction-formatters"; +import { prepareBudgetPerformanceData } from "./csv/csv-budget-formatters"; +import { prepareFinancialOverviewData } from "./csv/csv-overview-formatters"; export class CSVService { async generateCSV(report: Report): Promise { @@ -8,19 +22,34 @@ export class CSVService { switch (report.type) { case ReportType.GOALS_BY_STATUS: - ({ fields, data } = this.prepareGoalsByStatusData(report.data)); + ({ fields, data } = prepareGoalsByStatusData(report.data)); break; case ReportType.GOALS_BY_CATEGORY: - ({ fields, data } = this.prepareGoalsByCategoryData(report.data)); + ({ fields, data } = prepareGoalsByCategoryData(report.data)); break; case ReportType.CONTRIBUTIONS_BY_GOAL: - ({ fields, data } = this.prepareContributionsByGoalData(report.data)); + ({ fields, data } = prepareContributionsByGoalData(report.data)); break; case ReportType.SAVINGS_COMPARISON: - ({ fields, data } = this.prepareSavingsComparisonData(report.data)); + ({ fields, data } = prepareSavingsComparisonData(report.data)); break; case ReportType.SAVINGS_SUMMARY: - ({ fields, data } = this.prepareSavingsSummaryData(report.data)); + ({ fields, data } = prepareSavingsSummaryData(report.data)); + break; + case ReportType.TRANSACTIONS_SUMMARY: + ({ fields, data } = prepareTransactionsSummaryData(report.data)); + break; + case ReportType.EXPENSES_BY_CATEGORY: + ({ fields, data } = prepareExpensesByCategoryData(report.data)); + break; + case ReportType.MONTHLY_TREND: + ({ fields, data } = prepareMonthlyTrendData(report.data)); + break; + case ReportType.BUDGET_PERFORMANCE: + ({ fields, data } = prepareBudgetPerformanceData(report.data)); + break; + case ReportType.FINANCIAL_OVERVIEW: + ({ fields, data } = prepareFinancialOverviewData(report.data)); break; default: throw new Error( @@ -31,149 +60,4 @@ export class CSVService { const parser = new Parser({ fields }); return parser.parse(data); } - - private prepareGoalsByStatusData(data: any): { - fields: string[]; - data: any[]; - } { - const fields = [ - "name", - "status", - "targetAmount", - "currentAmount", - "progress", - "deadline", - ]; - const rows = data.goals.map((goal: any) => ({ - name: goal.name, - status: goal.status, - targetAmount: goal.targetAmount, - currentAmount: goal.currentAmount, - progress: goal.progress, - deadline: goal.deadline, - })); - - // Add summary rows - rows.push( - {}, - { name: "Summary" }, - { name: "Total Goals", status: data.total }, - { name: "Completed Goals", status: data.completed }, - { name: "Expired Goals", status: data.expired }, - { name: "In Progress Goals", status: data.inProgress } - ); - - return { fields, data: rows }; - } - - private prepareGoalsByCategoryData(data: any): { - fields: string[]; - data: any[]; - } { - const fields = [ - "category", - "totalGoals", - "totalAmount", - "completedAmount", - "progress", - ]; - const rows: any[] = []; - - data.categories.forEach((category: any) => { - rows.push({ - category: category.name, - totalGoals: category.totalGoals, - totalAmount: category.totalAmount, - completedAmount: category.completedAmount, - progress: category.progress, - }); - - category.goals.forEach((goal: any) => { - rows.push({ - category: ` ${goal.name}`, - totalGoals: "", - totalAmount: goal.targetAmount, - completedAmount: goal.currentAmount, - progress: goal.progress, - }); - }); - - rows.push({}); - }); - - return { fields, data: rows }; - } - - private prepareContributionsByGoalData(data: any): { - fields: string[]; - data: any[]; - } { - const fields = ["date", "amount", "transactionId"]; - const rows = [ - { date: "Goal Name", amount: data.goalName }, - {}, - ...data.contributions.map((contribution: any) => ({ - date: contribution.date, - amount: contribution.amount, - transactionId: contribution.transactionId, - })), - {}, - { date: "Total Contributions", amount: data.totalContributions }, - { date: "Average Contribution", amount: data.averageContribution }, - { date: "Last Contribution", amount: data.lastContributionDate }, - ]; - - return { fields, data: rows }; - } - - private prepareSavingsComparisonData(data: any): { - fields: string[]; - data: any[]; - } { - const fields = ["date", "plannedAmount", "actualAmount", "difference"]; - const rows = [ - { date: "Goal Name", plannedAmount: data.goalName }, - {}, - ...data.deviations.map((deviation: any) => ({ - date: deviation.date, - plannedAmount: deviation.plannedAmount, - actualAmount: deviation.actualAmount, - difference: deviation.difference, - })), - ]; - - return { fields, data: rows }; - } - - private prepareSavingsSummaryData(data: any): { - fields: string[]; - data: any[]; - } { - const fields = ["metric", "value"]; - const rows = [ - { metric: "Total Goals", value: data.totalGoals }, - { metric: "Total Target Amount", value: data.totalTargetAmount }, - { metric: "Total Current Amount", value: data.totalCurrentAmount }, - { metric: "Overall Progress (%)", value: data.overallProgress }, - { metric: "Completed Goals", value: data.completedGoals }, - { metric: "Expired Goals", value: data.expiredGoals }, - { metric: "In Progress Goals", value: data.inProgressGoals }, - { metric: "Average Contribution", value: data.averageContribution }, - { metric: "Last Contribution Date", value: data.lastContributionDate }, - {}, - { metric: "Category Breakdown" }, - ]; - - data.categoryBreakdown.forEach((category: any) => { - rows.push( - { metric: category.categoryName }, - { metric: " Total Goals", value: category.totalGoals }, - { metric: " Total Amount", value: category.totalAmount }, - { metric: " Progress (%)", value: category.progress }, - {} - ); - }); - - return { fields, data: rows }; - } } diff --git a/src/features/reports/infrastructure/services/csv/csv-budget-formatters.ts b/src/features/reports/infrastructure/services/csv/csv-budget-formatters.ts new file mode 100644 index 0000000..81d10e1 --- /dev/null +++ b/src/features/reports/infrastructure/services/csv/csv-budget-formatters.ts @@ -0,0 +1,58 @@ +export function prepareBudgetPerformanceData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = [ + "nombreCategoria", + "mes", + "montoLimite", + "montoActual", + "porcentaje", + "estado", + ]; + + const rows = [ + { + nombreCategoria: "Resumen", + mes: "Total Presupuestos", + montoLimite: data.totalBudgets, + }, + { + nombreCategoria: "Resumen", + mes: "Cantidad Excedidos", + montoLimite: data.exceededCount, + }, + { + nombreCategoria: "Resumen", + mes: "Cantidad Advertencia", + montoLimite: data.warningCount, + }, + { + nombreCategoria: "Resumen", + mes: "Cantidad Buenos", + montoLimite: data.goodCount, + }, + {}, + { nombreCategoria: "Detalles Presupuestos" }, + ]; + + data.budgets?.forEach((budget: any) => { + const statusLabel = + budget.status === "exceeded" + ? "EXCEDIDO" + : budget.status === "warning" + ? "ADVERTENCIA" + : "BUENO"; + + rows.push({ + nombreCategoria: budget.categoryName || "Sin categoría", + mes: budget.month || "N/A", + montoLimite: budget.limitAmount, + montoActual: budget.currentAmount, + porcentaje: budget.percentage, + estado: statusLabel, + } as any); + }); + + return { fields, data: rows }; +} diff --git a/src/features/reports/infrastructure/services/csv/csv-goal-formatters.ts b/src/features/reports/infrastructure/services/csv/csv-goal-formatters.ts new file mode 100644 index 0000000..cd355bc --- /dev/null +++ b/src/features/reports/infrastructure/services/csv/csv-goal-formatters.ts @@ -0,0 +1,152 @@ +import { Parser } from "json2csv"; +import { formatDate, formatDateOnly } from "./date-formatter"; + +export function prepareGoalsByStatusData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = [ + "nombre", + "estado", + "montoObjetivo", + "montoActual", + "progreso", + "fechaLimite", + ]; + const rows = data.goals.map((goal: any) => ({ + nombre: goal.name, + estado: goal.status, + montoObjetivo: goal.targetAmount, + montoActual: goal.currentAmount, + progreso: goal.progress, + fechaLimite: formatDateOnly(goal.deadline), + })); + + rows.push( + {}, + { nombre: "Resumen" }, + { nombre: "Total Metas", estado: data.total }, + { nombre: "Metas Completadas", estado: data.completed }, + { nombre: "Metas Expiradas", estado: data.expired }, + { nombre: "Metas en Progreso", estado: data.inProgress } + ); + + return { fields, data: rows }; +} + +export function prepareGoalsByCategoryData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = [ + "categoria", + "totalMetas", + "montoTotal", + "montoCompletado", + "progreso", + ]; + const rows: any[] = []; + + data.categories.forEach((category: any) => { + rows.push({ + categoria: category.name, + totalMetas: category.totalGoals, + montoTotal: category.totalAmount, + montoCompletado: category.completedAmount, + progreso: category.progress, + }); + + category.goals.forEach((goal: any) => { + rows.push({ + categoria: ` ${goal.name}`, + totalMetas: "", + montoTotal: goal.targetAmount, + montoCompletado: goal.currentAmount, + progreso: goal.progress, + }); + }); + + rows.push({}); + }); + + return { fields, data: rows }; +} + +export function prepareContributionsByGoalData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = ["fecha", "monto", "idTransaccion"]; + const rows = [ + { fecha: "Nombre de Meta", monto: data.goalName }, + {}, + ...data.contributions.map((contribution: any) => ({ + fecha: formatDate(contribution.date), + monto: contribution.amount, + idTransaccion: contribution.transactionId, + })), + {}, + { fecha: "Total Contribuciones", monto: data.totalContributions }, + { fecha: "Contribución Promedio", monto: data.averageContribution }, + { + fecha: "Última Contribución", + monto: formatDate(data.lastContributionDate), + }, + ]; + + return { fields, data: rows }; +} + +export function prepareSavingsComparisonData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = ["fecha", "montoPlanificado", "montoReal", "diferencia"]; + const rows = [ + { fecha: "Nombre de Meta", montoPlanificado: data.goalName }, + {}, + ...data.deviations.map((deviation: any) => ({ + fecha: formatDate(deviation.date), + montoPlanificado: deviation.plannedAmount, + montoReal: deviation.actualAmount, + diferencia: deviation.difference, + })), + ]; + + return { fields, data: rows }; +} + +export function prepareSavingsSummaryData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = ["metrica", "valor"]; + const rows = [ + { metrica: "Total Metas", valor: data.totalGoals }, + { metrica: "Monto Total Objetivo", valor: data.totalTargetAmount }, + { metrica: "Monto Total Actual", valor: data.totalCurrentAmount }, + { metrica: "Progreso General (%)", valor: data.overallProgress }, + { metrica: "Metas Completadas", valor: data.completedGoals }, + { metrica: "Metas Expiradas", valor: data.expiredGoals }, + { metrica: "Metas en Progreso", valor: data.inProgressGoals }, + { metrica: "Contribución Promedio", valor: data.averageContribution }, + { + metrica: "Fecha Última Contribución", + valor: formatDate(data.lastContributionDate), + }, + {}, + { metrica: "Desglose por Categoría" }, + ]; + + data.categoryBreakdown.forEach((category: any) => { + rows.push( + { metrica: category.categoryName }, + { metrica: " Total Metas", valor: category.totalGoals }, + { metrica: " Monto Total", valor: category.totalAmount }, + { metrica: " Progreso (%)", valor: category.progress }, + {} + ); + }); + + return { fields, data: rows }; +} diff --git a/src/features/reports/infrastructure/services/csv/csv-overview-formatters.ts b/src/features/reports/infrastructure/services/csv/csv-overview-formatters.ts new file mode 100644 index 0000000..afdaa19 --- /dev/null +++ b/src/features/reports/infrastructure/services/csv/csv-overview-formatters.ts @@ -0,0 +1,109 @@ +import { formatDateOnly } from "./date-formatter"; + +export function prepareFinancialOverviewData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = ["seccion", "metrica", "valor", "detalles"]; + + const rows = [ + { + seccion: "Período", + metrica: "Fecha Inicio", + valor: formatDateOnly(data.period?.startDate), + }, + { + seccion: "Período", + metrica: "Fecha Fin", + valor: formatDateOnly(data.period?.endDate), + }, + {}, + { + seccion: "Resumen", + metrica: "Total Ingresos", + valor: data.summary?.totalIncome, + }, + { + seccion: "Resumen", + metrica: "Total Gastos", + valor: data.summary?.totalExpense, + }, + { + seccion: "Resumen", + metrica: "Balance Neto", + valor: data.summary?.netBalance, + }, + { + seccion: "Resumen", + metrica: "Tasa de Ahorro (%)", + valor: data.summary?.savingsRate, + }, + {}, + { seccion: "Metas", metrica: "Total", valor: data.goals?.total }, + { seccion: "Metas", metrica: "Completadas", valor: data.goals?.completed }, + { seccion: "Metas", metrica: "En Progreso", valor: data.goals?.inProgress }, + { + seccion: "Metas", + metrica: "Total Ahorrado", + valor: data.goals?.totalSaved, + }, + { + seccion: "Metas", + metrica: "Total Objetivo", + valor: data.goals?.totalTarget, + }, + { + seccion: "Metas", + metrica: "Progreso General (%)", + valor: data.goals?.overallProgress, + }, + {}, + { seccion: "Presupuestos", metrica: "Total", valor: data.budgets?.total }, + { + seccion: "Presupuestos", + metrica: "Excedidos", + valor: data.budgets?.exceeded, + }, + { + seccion: "Presupuestos", + metrica: "Utilización Promedio (%)", + valor: data.budgets?.averageUtilization, + }, + {}, + { seccion: "Deudas", metrica: "Total", valor: data.debts?.total }, + { + seccion: "Deudas", + metrica: "Monto Total", + valor: data.debts?.totalAmount, + }, + { + seccion: "Deudas", + metrica: "Total Pendiente", + valor: data.debts?.totalPending, + }, + {}, + { seccion: "Categorías Top Gastos" }, + ]; + + data.topCategories?.expenses?.forEach((category: any) => { + rows.push({ + seccion: "Categorías Top Gastos", + metrica: category.name || "Sin nombre", + valor: category.amount, + detalles: `${category.percentage?.toFixed(1)}%`, + } as any); + }); + + rows.push({}, { seccion: "Categorías Top Ingresos" }); + + data.topCategories?.income?.forEach((category: any) => { + rows.push({ + seccion: "Categorías Top Ingresos", + metrica: category.name || "Sin nombre", + valor: category.amount, + detalles: `${category.percentage?.toFixed(1)}%`, + } as any); + }); + + return { fields, data: rows }; +} diff --git a/src/features/reports/infrastructure/services/csv/csv-transaction-formatters.ts b/src/features/reports/infrastructure/services/csv/csv-transaction-formatters.ts new file mode 100644 index 0000000..a4c2191 --- /dev/null +++ b/src/features/reports/infrastructure/services/csv/csv-transaction-formatters.ts @@ -0,0 +1,144 @@ +import { formatDate } from "./date-formatter"; + +export function prepareTransactionsSummaryData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = ["fecha", "tipo", "categoria", "monto", "descripcion"]; + + const rows = [ + { fecha: "Resumen", tipo: "Total Ingresos", monto: data.totalIncome }, + { fecha: "Resumen", tipo: "Total Gastos", monto: data.totalExpense }, + { fecha: "Resumen", tipo: "Balance Neto", monto: data.netBalance }, + { + fecha: "Resumen", + tipo: "Cantidad Transacciones", + monto: data.transactionCount, + }, + { fecha: "Resumen", tipo: "Cantidad Ingresos", monto: data.incomeCount }, + { fecha: "Resumen", tipo: "Cantidad Gastos", monto: data.expenseCount }, + { fecha: "Resumen", tipo: "Ingreso Promedio", monto: data.averageIncome }, + { fecha: "Resumen", tipo: "Gasto Promedio", monto: data.averageExpense }, + {}, + { fecha: "Categorías Principales" }, + ]; + + if (data.topIncomeCategory) { + rows.push({ + fecha: "Categoría Top Ingreso", + tipo: data.topIncomeCategory.name, + monto: data.topIncomeCategory.amount, + }); + } + + if (data.topExpenseCategory) { + rows.push({ + fecha: "Categoría Top Gasto", + tipo: data.topExpenseCategory.name, + monto: data.topExpenseCategory.amount, + }); + } + + rows.push({}, { fecha: "Transacciones" }); + + data.transactions?.forEach((transaction: any) => { + rows.push({ + fecha: formatDate(transaction.date), + tipo: transaction.type === "INCOME" ? "Ingreso" : "Gasto", + categoria: transaction.category || "Sin categoría", + monto: transaction.amount, + descripcion: transaction.description || "", + } as any); + }); + + return { fields, data: rows }; +} + +export function prepareExpensesByCategoryData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = ["categoria", "monto", "porcentaje", "cantidadTransacciones"]; + + const rows = [ + { + categoria: "Resumen", + monto: "Total Gastos", + porcentaje: data.totalExpenses, + }, + { + categoria: "Resumen", + monto: "Cantidad Categorías", + porcentaje: data.categoryCount, + }, + {}, + { categoria: "Categorías" }, + ]; + + data.categories?.forEach((category: any) => { + rows.push({ + categoria: category.name, + monto: category.amount, + porcentaje: category.percentage, + cantidadTransacciones: category.transactionCount, + } as any); + + category.transactions?.forEach((transaction: any) => { + rows.push({ + categoria: ` ${transaction.description || "Sin descripción"}`, + monto: transaction.amount, + porcentaje: "", + cantidadTransacciones: formatDate(transaction.date), + } as any); + }); + + rows.push({}); + }); + + return { fields, data: rows }; +} + +export function prepareMonthlyTrendData(data: any): { + fields: string[]; + data: any[]; +} { + const fields = [ + "mes", + "ingresos", + "gastos", + "balance", + "cantidadTransacciones", + ]; + + const rows = [ + { + mes: "Resumen", + ingresos: "Ingreso Mensual Promedio", + gastos: data.averageMonthlyIncome, + }, + { + mes: "Resumen", + ingresos: "Gasto Mensual Promedio", + gastos: data.averageMonthlyExpense, + }, + { + mes: "Resumen", + ingresos: "Tendencia", + gastos: data.trend?.toUpperCase(), + }, + {}, + { mes: "Datos Mensuales" }, + ]; + + data.months?.forEach((month: any) => { + rows.push({ + mes: month.month, + ingresos: month.income, + gastos: month.expense, + balance: month.balance, + cantidadTransacciones: month.transactionCount, + } as any); + }); + + return { fields, data: rows }; +} diff --git a/src/features/reports/infrastructure/services/csv/date-formatter.ts b/src/features/reports/infrastructure/services/csv/date-formatter.ts new file mode 100644 index 0000000..ae135d8 --- /dev/null +++ b/src/features/reports/infrastructure/services/csv/date-formatter.ts @@ -0,0 +1,41 @@ +export function formatDate(date: any): string { + if (!date) return ""; + + try { + const dateObj = new Date(date); + + if (isNaN(dateObj.getTime())) { + return String(date); + } + + return dateObj.toLocaleDateString("es-ES", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } catch (error) { + return String(date); + } +} + +export function formatDateOnly(date: any): string { + if (!date) return ""; + + try { + const dateObj = new Date(date); + + if (isNaN(dateObj.getTime())) { + return String(date); + } + + return dateObj.toLocaleDateString("es-ES", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + } catch (error) { + return String(date); + } +} diff --git a/src/features/reports/infrastructure/services/excel.service.ts b/src/features/reports/infrastructure/services/excel.service.ts index 4db9eb5..e8ca6d7 100644 --- a/src/features/reports/infrastructure/services/excel.service.ts +++ b/src/features/reports/infrastructure/services/excel.service.ts @@ -1,5 +1,19 @@ import { Report, ReportType } from "../../domain/entities/report.entity"; import { Workbook, Worksheet } from "exceljs"; +import { + formatGoalsByStatus, + formatGoalsByCategory, + formatContributionsByGoal, + formatSavingsComparison, + formatSavingsSummary, +} from "./excel/excel-goal-formatters"; +import { + formatTransactionsSummary, + formatExpensesByCategory, + formatMonthlyTrend, +} from "./excel/excel-transaction-formatters"; +import { formatBudgetPerformance } from "./excel/excel-budget-formatters"; +import { formatFinancialOverview } from "./excel/excel-overview-formatters"; export class ExcelService { async generateExcel(report: Report): Promise { @@ -8,19 +22,34 @@ export class ExcelService { switch (report.type) { case ReportType.GOALS_BY_STATUS: - this.formatGoalsByStatus(worksheet, report.data); + formatGoalsByStatus(worksheet, report.data); break; case ReportType.GOALS_BY_CATEGORY: - this.formatGoalsByCategory(worksheet, report.data); + formatGoalsByCategory(worksheet, report.data); break; case ReportType.CONTRIBUTIONS_BY_GOAL: - this.formatContributionsByGoal(worksheet, report.data); + formatContributionsByGoal(worksheet, report.data); break; case ReportType.SAVINGS_COMPARISON: - this.formatSavingsComparison(worksheet, report.data); + formatSavingsComparison(worksheet, report.data); break; case ReportType.SAVINGS_SUMMARY: - this.formatSavingsSummary(worksheet, report.data); + formatSavingsSummary(worksheet, report.data); + break; + case ReportType.TRANSACTIONS_SUMMARY: + formatTransactionsSummary(worksheet, report.data); + break; + case ReportType.EXPENSES_BY_CATEGORY: + formatExpensesByCategory(worksheet, report.data); + break; + case ReportType.MONTHLY_TREND: + formatMonthlyTrend(worksheet, report.data); + break; + case ReportType.BUDGET_PERFORMANCE: + formatBudgetPerformance(worksheet, report.data); + break; + case ReportType.FINANCIAL_OVERVIEW: + formatFinancialOverview(worksheet, report.data); break; default: throw new Error( @@ -30,126 +59,4 @@ export class ExcelService { return (await workbook.xlsx.writeBuffer()) as unknown as Buffer; } - - private formatGoalsByStatus(worksheet: Worksheet, data: any) { - worksheet.addRow([ - "Name", - "Status", - "Target Amount", - "Current Amount", - "Progress", - "Deadline", - ]); - - data.goals?.forEach((goal: any) => { - worksheet.addRow([ - goal.name, - goal.status, - goal.targetAmount, - goal.currentAmount, - goal.progress, - goal.deadline, - ]); - }); - - worksheet.addRow([]); - worksheet.addRow(["Summary"]); - worksheet.addRow(["Total Goals", data.total]); - worksheet.addRow(["Completed Goals", data.completed]); - worksheet.addRow(["Expired Goals", data.expired]); - worksheet.addRow(["In Progress Goals", data.inProgress]); - } - - private formatGoalsByCategory(worksheet: Worksheet, data: any) { - worksheet.addRow([ - "Category", - "Total Goals", - "Total Amount", - "Completed Amount", - "Progress", - ]); - - data.categories.forEach((category: any) => { - worksheet.addRow([ - category.name, - category.totalGoals, - category.totalAmount, - category.completedAmount, - category.progress, - ]); - - category.goals.forEach((goal: any) => { - worksheet.addRow([ - ` ${goal.name}`, - "", - goal.targetAmount, - goal.currentAmount, - goal.progress, - ]); - }); - - worksheet.addRow([]); - }); - } - - private formatContributionsByGoal(worksheet: Worksheet, data: any) { - worksheet.addRow(["Date", "Amount", "Transaction ID"]); - - worksheet.addRow(["Goal Name", data.goalName]); - worksheet.addRow([]); - - data.contributions.forEach((contribution: any) => { - worksheet.addRow([ - contribution.date, - contribution.amount, - contribution.transactionId, - ]); - }); - - worksheet.addRow([]); - worksheet.addRow(["Total Contributions", data.totalContributions]); - worksheet.addRow(["Average Contribution", data.averageContribution]); - worksheet.addRow(["Last Contribution", data.lastContributionDate]); - } - - private formatSavingsComparison(worksheet: Worksheet, data: any) { - worksheet.addRow(["Date", "Planned Amount", "Actual Amount", "Difference"]); - - worksheet.addRow(["Goal Name", data.goalName]); - worksheet.addRow([]); - - data.deviations.forEach((deviation: any) => { - worksheet.addRow([ - deviation.date, - deviation.plannedAmount, - deviation.actualAmount, - deviation.difference, - ]); - }); - } - - private formatSavingsSummary(worksheet: Worksheet, data: any) { - worksheet.addRow(["Metric", "Value"]); - - worksheet.addRow(["Total Goals", data.totalGoals]); - worksheet.addRow(["Total Target Amount", data.totalTargetAmount]); - worksheet.addRow(["Total Current Amount", data.totalCurrentAmount]); - worksheet.addRow(["Overall Progress (%)", data.overallProgress]); - worksheet.addRow(["Completed Goals", data.completedGoals]); - worksheet.addRow(["Expired Goals", data.expiredGoals]); - worksheet.addRow(["In Progress Goals", data.inProgressGoals]); - worksheet.addRow(["Average Contribution", data.averageContribution]); - worksheet.addRow(["Last Contribution Date", data.lastContributionDate]); - - worksheet.addRow([]); - worksheet.addRow(["Category Breakdown"]); - - data.categoryBreakdown.forEach((category: any) => { - worksheet.addRow([category.categoryName]); - worksheet.addRow([" Total Goals", category.totalGoals]); - worksheet.addRow([" Total Amount", category.totalAmount]); - worksheet.addRow([" Progress (%)", category.progress]); - worksheet.addRow([]); - }); - } } diff --git a/src/features/reports/infrastructure/services/excel/excel-budget-formatters.ts b/src/features/reports/infrastructure/services/excel/excel-budget-formatters.ts new file mode 100644 index 0000000..82b15d3 --- /dev/null +++ b/src/features/reports/infrastructure/services/excel/excel-budget-formatters.ts @@ -0,0 +1,38 @@ +import { Worksheet } from "exceljs"; + +export function formatBudgetPerformance(worksheet: Worksheet, data: any) { + worksheet.addRow(["Resumen"]); + worksheet.addRow(["Total Presupuestos", data.totalBudgets]); + worksheet.addRow(["Cantidad Excedidos", data.exceededCount]); + worksheet.addRow(["Cantidad Advertencia", data.warningCount]); + worksheet.addRow(["Cantidad Buenos", data.goodCount]); + worksheet.addRow([]); + + worksheet.addRow(["Detalles Presupuestos"]); + worksheet.addRow([ + "Categoría", + "Mes", + "Monto Límite", + "Monto Actual", + "Porcentaje", + "Estado", + ]); + + data.budgets?.forEach((budget: any) => { + const statusLabel = + budget.status === "exceeded" + ? "EXCEDIDO" + : budget.status === "warning" + ? "ADVERTENCIA" + : "BUENO"; + + worksheet.addRow([ + budget.categoryName || "Sin categoría", + budget.month || "N/A", + budget.limitAmount, + budget.currentAmount, + budget.percentage, + statusLabel, + ]); + }); +} diff --git a/src/features/reports/infrastructure/services/excel/excel-goal-formatters.ts b/src/features/reports/infrastructure/services/excel/excel-goal-formatters.ts new file mode 100644 index 0000000..e1caee4 --- /dev/null +++ b/src/features/reports/infrastructure/services/excel/excel-goal-formatters.ts @@ -0,0 +1,130 @@ +import { Worksheet } from "exceljs"; +import { formatDate, formatDateOnly } from "../csv/date-formatter"; + +export function formatGoalsByStatus(worksheet: Worksheet, data: any) { + worksheet.addRow([ + "Nombre", + "Estado", + "Monto Objetivo", + "Monto Actual", + "Progreso", + "Fecha Límite", + ]); + + data.goals?.forEach((goal: any) => { + worksheet.addRow([ + goal.name, + goal.status, + goal.targetAmount, + goal.currentAmount, + goal.progress, + formatDateOnly(goal.deadline), + ]); + }); + + worksheet.addRow([]); + worksheet.addRow(["Resumen"]); + worksheet.addRow(["Total Metas", data.total]); + worksheet.addRow(["Metas Completadas", data.completed]); + worksheet.addRow(["Metas Expiradas", data.expired]); + worksheet.addRow(["Metas en Progreso", data.inProgress]); +} + +export function formatGoalsByCategory(worksheet: Worksheet, data: any) { + worksheet.addRow([ + "Categoría", + "Total Metas", + "Monto Total", + "Monto Completado", + "Progreso", + ]); + + data.categories.forEach((category: any) => { + worksheet.addRow([ + category.name, + category.totalGoals, + category.totalAmount, + category.completedAmount, + category.progress, + ]); + + category.goals.forEach((goal: any) => { + worksheet.addRow([ + ` ${goal.name}`, + "", + goal.targetAmount, + goal.currentAmount, + goal.progress, + ]); + }); + + worksheet.addRow([]); + }); +} + +export function formatContributionsByGoal(worksheet: Worksheet, data: any) { + worksheet.addRow(["Fecha", "Monto", "ID Transacción"]); + + worksheet.addRow(["Nombre de Meta", data.goalName]); + worksheet.addRow([]); + + data.contributions.forEach((contribution: any) => { + worksheet.addRow([ + formatDate(contribution.date), + contribution.amount, + contribution.transactionId, + ]); + }); + + worksheet.addRow([]); + worksheet.addRow(["Total Contribuciones", data.totalContributions]); + worksheet.addRow(["Contribución Promedio", data.averageContribution]); + worksheet.addRow([ + "Última Contribución", + formatDate(data.lastContributionDate), + ]); +} + +export function formatSavingsComparison(worksheet: Worksheet, data: any) { + worksheet.addRow(["Fecha", "Monto Planificado", "Monto Real", "Diferencia"]); + + worksheet.addRow(["Nombre de Meta", data.goalName]); + worksheet.addRow([]); + + data.deviations.forEach((deviation: any) => { + worksheet.addRow([ + formatDate(deviation.date), + deviation.plannedAmount, + deviation.actualAmount, + deviation.difference, + ]); + }); +} + +export function formatSavingsSummary(worksheet: Worksheet, data: any) { + worksheet.addRow(["Métrica", "Valor"]); + + worksheet.addRow(["Total Metas", data.totalGoals]); + worksheet.addRow(["Monto Total Objetivo", data.totalTargetAmount]); + worksheet.addRow(["Monto Total Actual", data.totalCurrentAmount]); + worksheet.addRow(["Progreso General (%)", data.overallProgress]); + worksheet.addRow(["Metas Completadas", data.completedGoals]); + worksheet.addRow(["Metas Expiradas", data.expiredGoals]); + worksheet.addRow(["Metas en Progreso", data.inProgressGoals]); + worksheet.addRow(["Contribución Promedio", data.averageContribution]); + worksheet.addRow([ + "Fecha Última Contribución", + formatDate(data.lastContributionDate), + ]); + + worksheet.addRow([]); + worksheet.addRow(["Desglose por Categoría"]); + + data.categoryBreakdown.forEach((category: any) => { + worksheet.addRow([category.categoryName]); + worksheet.addRow([" Total Metas", category.totalGoals]); + worksheet.addRow([" Monto Total", category.totalAmount]); + worksheet.addRow([" Progreso (%)", category.progress]); + worksheet.addRow([]); + }); +} diff --git a/src/features/reports/infrastructure/services/excel/excel-overview-formatters.ts b/src/features/reports/infrastructure/services/excel/excel-overview-formatters.ts new file mode 100644 index 0000000..c509e19 --- /dev/null +++ b/src/features/reports/infrastructure/services/excel/excel-overview-formatters.ts @@ -0,0 +1,68 @@ +import { Worksheet } from "exceljs"; +import { formatDateOnly } from "../csv/date-formatter"; + +export function formatFinancialOverview(worksheet: Worksheet, data: any) { + worksheet.addRow(["Período"]); + worksheet.addRow(["Fecha Inicio", formatDateOnly(data.period?.startDate)]); + worksheet.addRow(["Fecha Fin", formatDateOnly(data.period?.endDate)]); + worksheet.addRow([]); + + worksheet.addRow(["Resumen"]); + worksheet.addRow(["Total Ingresos", data.summary?.totalIncome]); + worksheet.addRow(["Total Gastos", data.summary?.totalExpense]); + worksheet.addRow(["Balance Neto", data.summary?.netBalance]); + worksheet.addRow(["Tasa de Ahorro (%)", data.summary?.savingsRate]); + worksheet.addRow([]); + + worksheet.addRow(["Metas"]); + worksheet.addRow(["Total", data.goals?.total]); + worksheet.addRow(["Completadas", data.goals?.completed]); + worksheet.addRow(["En Progreso", data.goals?.inProgress]); + worksheet.addRow(["Total Ahorrado", data.goals?.totalSaved]); + worksheet.addRow(["Total Objetivo", data.goals?.totalTarget]); + worksheet.addRow(["Progreso General (%)", data.goals?.overallProgress]); + worksheet.addRow([]); + + worksheet.addRow(["Presupuestos"]); + worksheet.addRow(["Total", data.budgets?.total]); + worksheet.addRow(["Excedidos", data.budgets?.exceeded]); + worksheet.addRow([ + "Utilización Promedio (%)", + data.budgets?.averageUtilization, + ]); + worksheet.addRow([]); + + worksheet.addRow(["Deudas"]); + worksheet.addRow(["Total", data.debts?.total]); + worksheet.addRow(["Monto Total", data.debts?.totalAmount]); + worksheet.addRow(["Total Pendiente", data.debts?.totalPending]); + worksheet.addRow([]); + + if (data.topCategories?.expenses && data.topCategories.expenses.length > 0) { + worksheet.addRow(["Categorías Top Gastos"]); + worksheet.addRow(["Categoría", "Monto", "Porcentaje"]); + + data.topCategories.expenses.forEach((category: any) => { + worksheet.addRow([ + category.name || "Sin nombre", + category.amount, + category.percentage, + ]); + }); + + worksheet.addRow([]); + } + + if (data.topCategories?.income && data.topCategories.income.length > 0) { + worksheet.addRow(["Categorías Top Ingresos"]); + worksheet.addRow(["Categoría", "Monto", "Porcentaje"]); + + data.topCategories.income.forEach((category: any) => { + worksheet.addRow([ + category.name || "Sin nombre", + category.amount, + category.percentage, + ]); + }); + } +} diff --git a/src/features/reports/infrastructure/services/excel/excel-transaction-formatters.ts b/src/features/reports/infrastructure/services/excel/excel-transaction-formatters.ts new file mode 100644 index 0000000..fa3cdae --- /dev/null +++ b/src/features/reports/infrastructure/services/excel/excel-transaction-formatters.ts @@ -0,0 +1,111 @@ +import { Worksheet } from "exceljs"; +import { formatDate } from "../csv/date-formatter"; + +export function formatTransactionsSummary(worksheet: Worksheet, data: any) { + worksheet.addRow(["Resumen"]); + worksheet.addRow(["Total Ingresos", data.totalIncome]); + worksheet.addRow(["Total Gastos", data.totalExpense]); + worksheet.addRow(["Balance Neto", data.netBalance]); + worksheet.addRow(["Cantidad Transacciones", data.transactionCount]); + worksheet.addRow(["Cantidad Ingresos", data.incomeCount]); + worksheet.addRow(["Cantidad Gastos", data.expenseCount]); + worksheet.addRow(["Ingreso Promedio", data.averageIncome]); + worksheet.addRow(["Gasto Promedio", data.averageExpense]); + worksheet.addRow([]); + + if (data.topIncomeCategory) { + worksheet.addRow([ + "Categoría Top Ingreso", + data.topIncomeCategory.name, + data.topIncomeCategory.amount, + ]); + } + + if (data.topExpenseCategory) { + worksheet.addRow([ + "Categoría Top Gasto", + data.topExpenseCategory.name, + data.topExpenseCategory.amount, + ]); + } + + worksheet.addRow([]); + worksheet.addRow(["Transacciones"]); + worksheet.addRow(["Fecha", "Tipo", "Categoría", "Monto", "Descripción"]); + + data.transactions?.forEach((transaction: any) => { + worksheet.addRow([ + formatDate(transaction.date), + transaction.type === "INCOME" ? "Ingreso" : "Gasto", + transaction.category || "Sin categoría", + transaction.amount, + transaction.description || "", + ]); + }); +} + +export function formatExpensesByCategory(worksheet: Worksheet, data: any) { + worksheet.addRow(["Resumen"]); + worksheet.addRow(["Total Gastos", data.totalExpenses]); + worksheet.addRow(["Cantidad Categorías", data.categoryCount]); + worksheet.addRow([]); + + worksheet.addRow(["Categorías"]); + worksheet.addRow([ + "Categoría", + "Monto", + "Porcentaje", + "Cantidad Transacciones", + ]); + + data.categories?.forEach((category: any) => { + worksheet.addRow([ + category.name, + category.amount, + category.percentage, + category.transactionCount, + ]); + + if (category.transactions && category.transactions.length > 0) { + worksheet.addRow(["Transacciones"]); + worksheet.addRow(["Descripción", "Monto", "Fecha"]); + + category.transactions.forEach((transaction: any) => { + worksheet.addRow([ + transaction.description || "Sin descripción", + transaction.amount, + formatDate(transaction.date), + ]); + }); + + worksheet.addRow([]); + } + }); +} + +export function formatMonthlyTrend(worksheet: Worksheet, data: any) { + worksheet.addRow(["Resumen"]); + worksheet.addRow(["Ingreso Mensual Promedio", data.averageMonthlyIncome]); + worksheet.addRow(["Gasto Mensual Promedio", data.averageMonthlyExpense]); + worksheet.addRow(["Tendencia", data.trend?.toUpperCase()]); + worksheet.addRow([]); + + worksheet.addRow(["Datos Mensuales"]); + worksheet.addRow([ + "Mes", + "Ingresos", + "Gastos", + "Balance", + "Cantidad Transacciones", + ]); + + data.months?.forEach((month: any) => { + worksheet.addRow([ + month.month, + month.income, + month.expense, + month.balance, + month.transactionCount, + ]); + }); +} diff --git a/src/features/reports/infrastructure/services/pdf.service.ts b/src/features/reports/infrastructure/services/pdf.service.ts index 2366f1e..2580e9e 100644 --- a/src/features/reports/infrastructure/services/pdf.service.ts +++ b/src/features/reports/infrastructure/services/pdf.service.ts @@ -1,37 +1,35 @@ import { Report, ReportType } from "../../domain/entities/report.entity"; import PDFDocument from "pdfkit"; -import path from "path"; -import fs from "fs/promises"; -import { PDFDocument as PDFLib, rgb, StandardFonts } from "pdf-lib"; +import { + addModernHeader, + addModernFooter, + addReportTitle, +} from "./pdf/pdf-layout"; +import { DIMENSIONS, COLORS } from "./pdf/pdf-utils"; +import { + formatGoalsByStatus, + formatGoalsByCategory, + formatContributionsByGoal, + formatSavingsComparison, + formatSavingsSummary, +} from "./pdf/pdf-goal-formatters"; +import { + formatTransactionsSummary, + formatExpensesByCategory, + formatMonthlyTrend, +} from "./pdf/pdf-transaction-formatters"; +import { formatBudgetPerformance } from "./pdf/pdf-budget-formatters"; +import { formatFinancialOverview } from "./pdf/pdf-overview-formatters"; export class PDFService { - private readonly HEADER_HEIGHT = 50; - private readonly FOOTER_HEIGHT = 30; - private readonly PRIMARY_COLOR = "#2c3e50"; - private readonly SECONDARY_COLOR = "#3498db"; - private readonly TEXT_COLOR = "#2c3e50"; - private readonly TABLE_HEADER_COLOR = "#f8f9fa"; - private readonly TABLE_BORDER_COLOR = "#dee2e6"; - private readonly ROW_HEIGHT = 25; - private readonly HEADER_HEIGHT_TABLE = 30; - private readonly MIN_SPACE_AFTER_TABLE = 30; - private readonly CELL_PADDING = 5; - private readonly ITEM_SPACING = 15; - private readonly SECTION_SPACING = 20; - async generatePDF(report: Report): Promise { - // Si es un reporte de tipo GOAL, usamos el template - if (report.type === ReportType.GOAL) { - return this.generateGoalPDF(report); - } - - // Para otros tipos de reportes, generamos dinámicamente const doc = new PDFDocument({ size: "A4", margin: 50, + bufferPages: true, info: { - Title: "Reporte Financiero", - Author: "Fopymes", + Title: `Reporte Financiero - ${this.getReportTypeLabel(report.type)}`, + Author: "FoppyAI", Subject: "Análisis Financiero", Keywords: "finanzas, reporte, metas, ahorro", CreationDate: new Date(), @@ -48,473 +46,113 @@ export class PDFService { }); doc.on("error", reject); - this.addHeader(doc); - doc.moveDown(2); - doc.fontSize(24).fillColor(this.PRIMARY_COLOR).text("Reporte", 50, 60); - doc.moveDown(); - - switch (report.type) { - case ReportType.GOALS_BY_STATUS: - this.formatGoalsByStatus(doc, report.data); - break; - case ReportType.GOALS_BY_CATEGORY: - this.formatGoalsByCategory(doc, report.data); - break; - case ReportType.CONTRIBUTIONS_BY_GOAL: - this.formatContributionsByGoal(doc, report.data); - break; - case ReportType.SAVINGS_COMPARISON: - this.formatSavingsComparison(doc, report.data); - break; - case ReportType.SAVINGS_SUMMARY: - this.formatSavingsSummary(doc, report.data); - break; - default: - reject( - new Error(`Unsupported report type for PDF export: ${report.type}`) - ); - return; - } - - this.addFooter(doc); - doc.end(); - }); - } - - private async generateGoalPDF(report: Report): Promise { - try { - // Cargar el template PDF - const templatePath = path.join( - __dirname, - "../../domain/templates/goals.pdf" - ); - const existingPdfBytes = await fs.readFile(templatePath); - const pdfDoc = await PDFLib.load(new Uint8Array(existingPdfBytes)); - - // Obtener la primera página - const page = pdfDoc.getPages()[0]; - const { width, height } = page.getSize(); - - // Añadir el contenido al template - const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica); - - // Configurar el contenido - page.drawText(`Meta: ${report.data.name}`, { - x: 50, - y: height - 150, - size: 16, - font: helveticaFont, - color: rgb(0.17, 0.24, 0.31), // PRIMARY_COLOR en RGB - }); - - // Añadir más información de la meta - const yPositions = { - targetAmount: height - 180, - currentAmount: height - 210, - progress: height - 240, - endDate: height - 270, - }; - - page.drawText( - `Meta: $${report.data?.targetAmount?.toLocaleString() || "0"}`, - { - x: 50, - y: yPositions.targetAmount, - size: 12, - font: helveticaFont, + try { + addModernHeader(doc, this.getReportTypeLabel(report.type)); + doc.moveDown(1); + addReportTitle(doc, this.getReportTypeLabel(report.type)); + doc.moveDown(1); + + const reportTitle = this.getReportTypeLabel(report.type); + + switch (report.type) { + case ReportType.GOALS_BY_STATUS: + formatGoalsByStatus(doc, report.data, reportTitle); + break; + case ReportType.GOALS_BY_CATEGORY: + formatGoalsByCategory(doc, report.data, reportTitle); + break; + case ReportType.CONTRIBUTIONS_BY_GOAL: + formatContributionsByGoal(doc, report.data, reportTitle); + break; + case ReportType.SAVINGS_COMPARISON: + formatSavingsComparison(doc, report.data, reportTitle); + break; + case ReportType.SAVINGS_SUMMARY: + formatSavingsSummary(doc, report.data, reportTitle); + break; + case ReportType.TRANSACTIONS_SUMMARY: + formatTransactionsSummary(doc, report.data, reportTitle); + break; + case ReportType.EXPENSES_BY_CATEGORY: + formatExpensesByCategory(doc, report.data, reportTitle); + break; + case ReportType.MONTHLY_TREND: + formatMonthlyTrend(doc, report.data, reportTitle); + break; + case ReportType.BUDGET_PERFORMANCE: + formatBudgetPerformance(doc, report.data, reportTitle); + break; + case ReportType.FINANCIAL_OVERVIEW: + formatFinancialOverview(doc, report.data, reportTitle); + break; + default: + throw new Error( + `Unsupported report type for PDF export: ${report.type}` + ); } - ); - page.drawText( - `Ahorrado: $${report.data?.currentAmount?.toLocaleString() || "0"}`, - { - x: 50, - y: yPositions.currentAmount, - size: 12, - font: helveticaFont, - } - ); - - const progress = ( - (report.data?.currentAmount / report.data?.targetAmount) * - 100 - ).toFixed(2); - page.drawText(`Progreso: ${progress}%`, { - x: 50, - y: yPositions.progress, - size: 12, - font: helveticaFont, - }); - - page.drawText( - `Fecha límite: ${ - report.data?.endDate - ? new Date(report.data.endDate).toLocaleDateString() - : "No disponible" - }`, - { - x: 50, - y: yPositions.endDate, - size: 12, - font: helveticaFont, + const pageRange = doc.bufferedPageRange(); + const pageCount = pageRange.count; + + for (let i = 0; i < pageCount; i++) { + doc.switchToPage(i); + + const savedY = doc.y; + const savedX = doc.x; + const footerY = DIMENSIONS.PAGE_HEIGHT - DIMENSIONS.FOOTER_HEIGHT; + + doc + .rect(0, footerY, DIMENSIONS.PAGE_WIDTH, 2) + .fillColor(COLORS.MEDIUM_GRAY) + .fill(); + + doc + .fontSize(8) + .fillColor(COLORS.TEXT) + .font("Helvetica"); + + const textWidth = doc.widthOfString(`© ${new Date().getFullYear()} FoppyAI. Todos los derechos reservados.`); + const textX = (DIMENSIONS.PAGE_WIDTH - textWidth) / 2; + + doc.text( + `© ${new Date().getFullYear()} FoppyAI. Todos los derechos reservados.`, + textX, + footerY + 15, + { + lineBreak: false, + continued: false + } + ); + + doc.x = savedX; + doc.y = savedY; } - ); - - // Guardar el PDF modificado - const pdfBytes = await pdfDoc.save(); - return Buffer.from(pdfBytes); - } catch (error) { - console.error("Error al generar el PDF de meta:", error); - throw new Error("No se pudo generar el PDF de meta"); - } - } - - private addHeader(doc: typeof PDFDocument) { - doc - .rect(0, 0, doc.page.width, this.HEADER_HEIGHT) - .fillColor(this.PRIMARY_COLOR) - .fill(); - - doc - .fontSize(16) - .fillColor("#ffffff") - .text("Reporte Financiero de Fopymes", 50, 20); - - const date = new Date().toLocaleDateString(); - doc.fontSize(10).text(date, doc.page.width - 100, 20, { align: "right" }); - } - - private addFooter(doc: typeof PDFDocument) { - const pageHeight = doc.page.height; - doc - .rect( - 0, - pageHeight - this.FOOTER_HEIGHT, - doc.page.width, - this.FOOTER_HEIGHT - ) - .fillColor(this.PRIMARY_COLOR) - .fill(); - - doc - .fontSize(8) - .fillColor("#ffffff") - .text( - "© 2025 Fopymes. Todos los derechos reservados.", - 50, - pageHeight - 20 - ); - - doc.text( - `Página ${doc.bufferedPageRange().start + 1} de ${ - doc.bufferedPageRange().count - }`, - doc.page.width - 100, - pageHeight - 20, - { align: "right" } - ); - } - - private checkPageBreak( - doc: typeof PDFDocument, - requiredHeight: number - ): boolean { - const currentY = doc.y; - const pageHeight = doc.page.height; - const footerHeight = this.FOOTER_HEIGHT; - return currentY + requiredHeight > pageHeight - footerHeight; - } - - private addNewPage(doc: typeof PDFDocument) { - doc.addPage(); - this.addHeader(doc); - doc.moveDown(2); - } - - private drawSummaryTable( - doc: typeof PDFDocument, - headers: string[], - rows: any[][], - startX: number, - startY: number, - colWidths: number[] - ) { - const tableWidth = colWidths.reduce((a, b) => a + b, 0); - const tableHeight = - this.HEADER_HEIGHT_TABLE + rows.length * this.ROW_HEIGHT; - - if (this.checkPageBreak(doc, tableHeight)) { - this.addNewPage(doc); - startY = doc.y; - } - - // Draw header - doc - .rect(startX, startY, tableWidth, this.HEADER_HEIGHT_TABLE) - .fillColor(this.TABLE_HEADER_COLOR) - .fill() - .strokeColor(this.TABLE_BORDER_COLOR) - .stroke(); - - let x = startX; - headers.forEach((header, i) => { - doc - .fontSize(12) - .fillColor(this.PRIMARY_COLOR) - .text(header, x + this.CELL_PADDING, startY + this.CELL_PADDING, { - width: colWidths[i] - 2 * this.CELL_PADDING, - align: "left", - }); - x += colWidths[i]; - }); - - // Draw rows - rows.forEach((row, rowIndex) => { - const y = startY + this.HEADER_HEIGHT_TABLE + rowIndex * this.ROW_HEIGHT; - x = startX; - - doc - .rect(startX, y, tableWidth, this.ROW_HEIGHT) - .strokeColor(this.TABLE_BORDER_COLOR) - .stroke(); - - row.forEach((cell, colIndex) => { - doc - .fontSize(10) - .fillColor(this.TEXT_COLOR) - .text(cell.toString(), x + this.CELL_PADDING, y + this.CELL_PADDING, { - width: colWidths[colIndex] - 2 * this.CELL_PADDING, - align: "left", - }); - x += colWidths[colIndex]; - }); - }); - - doc.y = - startY + - this.HEADER_HEIGHT_TABLE + - rows.length * this.ROW_HEIGHT + - this.MIN_SPACE_AFTER_TABLE; - } - - private drawDataItem( - doc: typeof PDFDocument, - label: string, - value: string, - indent: number = 0 - ) { - if (this.checkPageBreak(doc, this.ITEM_SPACING)) { - this.addNewPage(doc); - } - - doc - .fontSize(12) - .fillColor(this.TEXT_COLOR) - .text(`${label}:`, 50 + indent, doc.y, { continued: true }) - .text(value, { align: "left" }); - doc.moveDown(); - } - - private formatGoalsByStatus(doc: typeof PDFDocument, data: any) { - doc - .fontSize(18) - .fillColor(this.SECONDARY_COLOR) - .text("Metas por estado", 50, 100); - doc.moveDown(); - data.goals.forEach((goal: any) => { - if (this.checkPageBreak(doc, this.SECTION_SPACING * 5)) { - this.addNewPage(doc); + doc.end(); + } catch (error) { + reject(error); } - - doc.fontSize(14).fillColor(this.PRIMARY_COLOR).text(goal.name); - doc.moveDown(); - - this.drawDataItem(doc, "Estado", goal.status, 20); - this.drawDataItem(doc, "Meta", goal.targetAmount, 20); - this.drawDataItem(doc, "Ahorrado", goal.currentAmount, 20); - this.drawDataItem(doc, "Progreso", `${goal.progress}%`, 20); - this.drawDataItem(doc, "Plazo", goal.deadline, 20); - doc.moveDown(); }); - - // Summary table - doc.fontSize(16).fillColor(this.SECONDARY_COLOR).text("Resumen"); - doc.moveDown(); - - const summaryHeaders = ["Métrica", "Valor"]; - const summaryRows = [ - ["Total Metas", data.total], - ["Metas completadas", data.completed], - ["Metas expiradas", data.expired], - ["Metas en progreso", data.inProgress], - ]; - - this.drawSummaryTable( - doc, - summaryHeaders, - summaryRows, - 50, - doc.y, - [150, 100] - ); } - private formatGoalsByCategory(doc: typeof PDFDocument, data: any) { - doc - .fontSize(18) - .fillColor(this.SECONDARY_COLOR) - .text("Metas de ahorro por categoría"); - doc.moveDown(); - - data.categories.forEach((category: any) => { - if (this.checkPageBreak(doc, this.SECTION_SPACING * 10)) { - this.addNewPage(doc); - } - - doc.fontSize(16).fillColor(this.PRIMARY_COLOR).text(category.name); - doc.moveDown(); - - this.drawDataItem(doc, "Total Metas", category.totalGoals, 20); - this.drawDataItem(doc, "Total Cantidad", category.totalAmount, 20); - this.drawDataItem(doc, "Ahorrado", category.completedAmount, 20); - this.drawDataItem(doc, "Progreso", `${category.progress}%`, 20); - doc.moveDown(); - - doc - .fontSize(14) - .fillColor(this.SECONDARY_COLOR) - .text("Metas en esta categoría:"); - doc.moveDown(); - - category.goals.forEach((goal: any) => { - this.drawDataItem(doc, "Meta de ahorro", goal.name, 40); - this.drawDataItem(doc, "Meta", goal.targetAmount, 40); - this.drawDataItem(doc, "Ahorrado", goal.currentAmount, 40); - this.drawDataItem(doc, "Progreso", `${goal.progress}%`, 40); - doc.moveDown(); - }); - }); - } - - private formatContributionsByGoal(doc: typeof PDFDocument, data: any) { - doc - .fontSize(18) - .fillColor(this.SECONDARY_COLOR) - .text("Contribuciones por meta"); - doc.moveDown(); - - doc.fontSize(16).fillColor(this.PRIMARY_COLOR).text(data.goalName); - doc.moveDown(); - - data.contributions.forEach((contribution: any) => { - if (this.checkPageBreak(doc, this.SECTION_SPACING * 3)) { - this.addNewPage(doc); - } - - this.drawDataItem(doc, "Fecha", contribution.date, 20); - this.drawDataItem(doc, "Cantidad", contribution.amount, 20); - this.drawDataItem( - doc, - "ID de transacción", - contribution.transactionId, - 20 - ); - doc.moveDown(); - }); - - // Summary table - doc.fontSize(16).fillColor(this.SECONDARY_COLOR).text("Resumen"); - doc.moveDown(); - - const summaryHeaders = ["Metric", "Value"]; - const summaryRows = [ - ["Total Contribuciones", data.totalContributions], - ["Contribución promedio", data.averageContribution], - ["Última contribución", data.lastContributionDate], - ]; - - this.drawSummaryTable( - doc, - summaryHeaders, - summaryRows, - 50, - doc.y, - [150, 100] - ); - } - - private formatSavingsComparison(doc: typeof PDFDocument, data: any) { - doc - .fontSize(18) - .fillColor(this.SECONDARY_COLOR) - .text("Comparación de ahorro"); - doc.moveDown(); - - doc.fontSize(16).fillColor(this.PRIMARY_COLOR).text(data.goalName); - doc.moveDown(); - - data.deviations.forEach((deviation: any) => { - if (this.checkPageBreak(doc, this.SECTION_SPACING * 4)) { - this.addNewPage(doc); - } - - this.drawDataItem(doc, "Fecha", deviation.date, 20); - this.drawDataItem(doc, "Meta", deviation.plannedAmount, 20); - this.drawDataItem(doc, "Ahorrado", deviation.actualAmount, 20); - this.drawDataItem(doc, "Diferencia", deviation.difference, 20); - doc.moveDown(); - }); - } - - private formatSavingsSummary(doc: typeof PDFDocument, data: any) { - doc.fontSize(18).fillColor(this.SECONDARY_COLOR).text("Resumen de ahorro"); - doc.moveDown(); - - // Overall metrics table - doc.fontSize(16).fillColor(this.PRIMARY_COLOR).text("Métricas generales"); - doc.moveDown(); - - const metricsHeaders = ["Métrica", "Valor"]; - const metricsRows = [ - ["Total Metas", data.totalGoals], - ["Meta total", data.totalTargetAmount], - ["Ahorrado total", data.totalCurrentAmount], - ["Progreso general", `${data.overallProgress}%`], - ["Metas completadas", data.completedGoals], - ["Metas expiradas", data.expiredGoals], - ["Metas en progreso", data.inProgressGoals], - ["Contribución promedio", data.averageContribution], - ["Última contribución", data.lastContributionDate], - ]; - - this.drawSummaryTable( - doc, - metricsHeaders, - metricsRows, - 50, - doc.y, - [200, 150] - ); - doc.moveDown(); - - // Category breakdown - doc - .fontSize(16) - .fillColor(this.PRIMARY_COLOR) - .text("Desglose por categoría"); - doc.moveDown(); - - data.categoryBreakdown.forEach((category: any) => { - if (this.checkPageBreak(doc, this.SECTION_SPACING * 4)) { - this.addNewPage(doc); - } - - this.drawDataItem(doc, "Categoría", category.categoryName, 20); - this.drawDataItem(doc, "Total Metas", category.totalGoals, 20); - this.drawDataItem(doc, "Total Cantidad", category.totalAmount, 20); - this.drawDataItem(doc, "Progreso", `${category.progress}%`, 20); - doc.moveDown(); - }); + private getReportTypeLabel(type: ReportType): string { + const labels: Record = { + [ReportType.GOAL]: "Reporte de Meta", + [ReportType.CONTRIBUTION]: "Reporte de Contribuciones", + [ReportType.BUDGET]: "Reporte de Presupuesto", + [ReportType.EXPENSE]: "Reporte de Gastos", + [ReportType.INCOME]: "Reporte de Ingresos", + [ReportType.GOALS_BY_STATUS]: "Metas por Estado", + [ReportType.GOALS_BY_CATEGORY]: "Metas por Categoría", + [ReportType.CONTRIBUTIONS_BY_GOAL]: "Contribuciones por Meta", + [ReportType.SAVINGS_COMPARISON]: "Comparación de Ahorro", + [ReportType.SAVINGS_SUMMARY]: "Resumen de Ahorro", + [ReportType.TRANSACTIONS_SUMMARY]: "Resumen de Transacciones", + [ReportType.EXPENSES_BY_CATEGORY]: "Gastos por Categoría", + [ReportType.MONTHLY_TREND]: "Tendencia Mensual", + [ReportType.BUDGET_PERFORMANCE]: "Rendimiento de Presupuestos", + [ReportType.FINANCIAL_OVERVIEW]: "Vista General Financiera", + }; + return labels[type] || "Reporte Financiero"; } } diff --git a/src/features/reports/infrastructure/services/pdf/pdf-budget-formatters.ts b/src/features/reports/infrastructure/services/pdf/pdf-budget-formatters.ts new file mode 100644 index 0000000..f4e2c47 --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-budget-formatters.ts @@ -0,0 +1,186 @@ +import PDFDocument from "pdfkit"; +import { BudgetPerformanceReport } from "../../../domain/entities/report.entity"; +import { + addSectionTitle, + addMetricCard, + drawProgressBar, +} from "./pdf-components"; +import { drawTable } from "./pdf-table"; +import { + formatCurrency, + COLORS, + DIMENSIONS, + checkPageBreak, +} from "./pdf-utils"; +import { addNewPage } from "./pdf-layout"; + +export function formatBudgetPerformance( + doc: typeof PDFDocument.prototype, + data: BudgetPerformanceReport, + reportTitle: string = "Rendimiento de Presupuestos" +): void { + if (!data) { + doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); + return; + } + + addSectionTitle(doc, "Rendimiento de Presupuestos", reportTitle); + + const cardY = doc.y; + const cardWidth = 110; + const cardSpacing = 15; + + addMetricCard( + doc, + "Total Presupuestos", + data.totalBudgets?.toString() || "0", + DIMENSIONS.MARGIN, + cardY, + cardWidth, + COLORS.SECONDARY + ); + addMetricCard( + doc, + "Excedidos", + data.exceededCount?.toString() || "0", + DIMENSIONS.MARGIN + cardWidth + cardSpacing, + cardY, + cardWidth, + COLORS.DANGER + ); + addMetricCard( + doc, + "Advertencia", + data.warningCount?.toString() || "0", + DIMENSIONS.MARGIN + 2 * (cardWidth + cardSpacing), + cardY, + cardWidth, + COLORS.WARNING + ); + addMetricCard( + doc, + "Buenos", + data.goodCount?.toString() || "0", + DIMENSIONS.MARGIN + 3 * (cardWidth + cardSpacing), + cardY, + cardWidth, + COLORS.ACCENT + ); + + doc.y = cardY + 70; + doc.moveDown(1); + + if (data.budgets && data.budgets.length > 0) { + addSectionTitle(doc, "Detalle de Presupuestos", reportTitle); + + data.budgets.forEach((budget, index) => { + if (checkPageBreak(doc, 120)) { + addNewPage(doc, reportTitle); + } + + if (index > 0) { + doc.moveDown(1); + } + + const statusColor = + budget.status === "exceeded" + ? COLORS.DANGER + : budget.status === "warning" + ? COLORS.WARNING + : COLORS.ACCENT; + const statusLabel = + budget.status === "exceeded" + ? "EXCEDIDO" + : budget.status === "warning" + ? "ADVERTENCIA" + : "BUENO"; + + doc + .fontSize(13) + .fillColor(COLORS.PRIMARY) + .font("Helvetica-Bold") + .text( + budget.categoryName || "Sin categoría", + DIMENSIONS.MARGIN, + doc.y, + { continued: true } + ) + .fontSize(9) + .fillColor(statusColor) + .text(` [${statusLabel}]`, { continued: false }); + + doc.moveDown(0.5); + + const col1X = DIMENSIONS.MARGIN; + const col2X = DIMENSIONS.MARGIN + 250; + const rowY = doc.y; + + doc + .fontSize(10) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text(`Límite: ${formatCurrency(budget.limitAmount || 0)}`, col1X, rowY) + .text(`Mes: ${budget.month || "N/A"}`, col2X, rowY); + + doc.moveDown(0.8); + const row2Y = doc.y; + + doc + .text( + `Actual: ${formatCurrency(budget.currentAmount || 0)}`, + col1X, + row2Y + ) + .text(`Uso: ${(budget.percentage || 0).toFixed(1)}%`, col2X, row2Y); + + doc.moveDown(1); + + drawProgressBar( + doc, + DIMENSIONS.MARGIN, + doc.y, + 450, + budget.percentage || 0 + ); + + doc.moveDown(1.5); + }); + + if (checkPageBreak(doc, 200)) { + addNewPage(doc, reportTitle); + } + + addSectionTitle(doc, "Resumen en Tabla", reportTitle); + + const budgetData = data.budgets.map((b) => [ + b.categoryName || "Sin categoría", + b.month || "N/A", + formatCurrency(b.limitAmount || 0), + formatCurrency(b.currentAmount || 0), + `${(b.percentage || 0).toFixed(1)}%`, + b.status === "exceeded" + ? "EXCEDIDO" + : b.status === "warning" + ? "ADVERTENCIA" + : "BUENO", + ]); + + const columnWidths = [140, 80, 100, 100, 80, 100]; + drawTable( + doc, + ["Categoría", "Mes", "Límite", "Actual", "Uso", "Estado"], + budgetData, + columnWidths, + reportTitle + ); + } else { + doc + .fontSize(11) + .fillColor(COLORS.TEXT) + .text( + "No se encontraron presupuestos para mostrar", + DIMENSIONS.MARGIN, + doc.y + ); + } +} diff --git a/src/features/reports/infrastructure/services/pdf/pdf-charts.ts b/src/features/reports/infrastructure/services/pdf/pdf-charts.ts new file mode 100644 index 0000000..a0c8739 --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-charts.ts @@ -0,0 +1,283 @@ +import PDFDocument from "pdfkit"; +import { COLORS, DIMENSIONS, checkPageBreak, formatCurrency } from "./pdf-utils"; +import { addNewPage } from "./pdf-layout"; + +export interface ChartDataPoint { + label: string; + value: number; + color?: string; +} + +export function drawBarChart( + doc: typeof PDFDocument, + data: ChartDataPoint[], + title: string, + width: number = 450, + height: number = 200 +) { + if (checkPageBreak(doc, height + 60)) { + addNewPage(doc); + } + + const startX = DIMENSIONS.MARGIN; + const startY = doc.y; + const chartWidth = width; + const chartHeight = height; + const barWidth = chartWidth / (data.length * 2); + const maxValue = Math.max(...data.map(d => d.value), 1); + + doc.fontSize(12) + .fillColor(COLORS.TEXT) + .font("Helvetica-Bold") + .text(title, startX, startY); + + doc.moveDown(0.5); + const chartStartY = doc.y; + + doc.rect(startX, chartStartY, chartWidth, chartHeight) + .strokeColor(COLORS.MEDIUM_GRAY) + .lineWidth(1) + .stroke(); + + const gridLines = 5; + for (let i = 0; i <= gridLines; i++) { + const y = chartStartY + (chartHeight / gridLines) * i; + doc.moveTo(startX, y) + .lineTo(startX + chartWidth, y) + .strokeColor(COLORS.LIGHT_GRAY) + .lineWidth(0.5) + .stroke(); + + const value = maxValue - (maxValue / gridLines) * i; + doc.fontSize(8) + .fillColor(COLORS.TEXT) + .text( + formatCurrency(value), + startX - 60, + y - 4, + { width: 50, align: "right" } + ); + } + + data.forEach((point, index) => { + const barHeight = (point.value / maxValue) * chartHeight; + const x = startX + (barWidth * 2 * index) + barWidth / 2; + const y = chartStartY + chartHeight - barHeight; + + doc.rect(x, y, barWidth, barHeight) + .fillColor(point.color || COLORS.SECONDARY) + .fill(); + + doc.fontSize(8) + .fillColor(COLORS.TEXT) + .text( + point.label, + x - 20, + chartStartY + chartHeight + 5, + { width: barWidth + 40, align: "center" } + ); + + doc.fontSize(7) + .text( + formatCurrency(point.value), + x - 20, + y - 12, + { width: barWidth + 40, align: "center" } + ); + }); + + doc.y = chartStartY + chartHeight + 40; +} + +export function drawPieChart( + doc: typeof PDFDocument, + data: ChartDataPoint[], + title: string, + size: number = 150 +) { + if (checkPageBreak(doc, size + 100)) { + addNewPage(doc); + } + + const startX = DIMENSIONS.MARGIN + size; + const startY = doc.y + size / 2; + const radius = size / 2; + + doc.fontSize(12) + .fillColor(COLORS.TEXT) + .font("Helvetica-Bold") + .text(title, DIMENSIONS.MARGIN, doc.y); + + doc.moveDown(1.5); + + const total = data.reduce((sum, d) => sum + d.value, 0); + let currentAngle = -Math.PI / 2; + + const colors = [ + COLORS.SECONDARY, + COLORS.ACCENT, + COLORS.WARNING, + COLORS.DANGER, + "#9333ea", + "#f97316", + ]; + + data.forEach((point, index) => { + const percentage = (point.value / total) * 100; + const angle = (point.value / total) * 2 * Math.PI; + const color = point.color || colors[index % colors.length]; + + doc.path( + `M ${startX} ${startY} ` + + `L ${startX + radius * Math.cos(currentAngle)} ${startY + radius * Math.sin(currentAngle)} ` + + `A ${radius} ${radius} 0 ${angle > Math.PI ? 1 : 0} 1 ` + + `${startX + radius * Math.cos(currentAngle + angle)} ${startY + radius * Math.sin(currentAngle + angle)} Z` + ) + .fillColor(color) + .fill(); + + currentAngle += angle; + }); + + const legendX = startX + radius + 40; + let legendY = startY - radius; + + data.forEach((point, index) => { + const color = point.color || colors[index % colors.length]; + const percentage = ((point.value / total) * 100).toFixed(1); + + doc.rect(legendX, legendY, 12, 12) + .fillColor(color) + .fill(); + + doc.fontSize(9) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text( + `${point.label} (${percentage}%)`, + legendX + 18, + legendY, + { width: 150 } + ); + + legendY += 20; + }); + + doc.y = Math.max(startY + radius + 20, legendY + 20); +} + +export function drawLineChart( + doc: typeof PDFDocument, + data: { label: string; values: { name: string; value: number; color: string }[] }[], + title: string, + width: number = 450, + height: number = 200 +) { + if (checkPageBreak(doc, height + 80)) { + addNewPage(doc); + } + + const startX = DIMENSIONS.MARGIN + 50; + const startY = doc.y; + const chartWidth = width - 50; + const chartHeight = height; + + doc.fontSize(12) + .fillColor(COLORS.TEXT) + .font("Helvetica-Bold") + .text(title, DIMENSIONS.MARGIN, startY); + + doc.moveDown(0.5); + const chartStartY = doc.y; + + const allValues = data.flatMap(d => d.values.map(v => v.value)); + const maxValue = Math.max(...allValues, 1); + const minValue = Math.min(...allValues, 0); + const valueRange = maxValue - minValue || 1; + + doc.rect(startX, chartStartY, chartWidth, chartHeight) + .strokeColor(COLORS.MEDIUM_GRAY) + .lineWidth(1) + .stroke(); + + const gridLines = 5; + for (let i = 0; i <= gridLines; i++) { + const y = chartStartY + (chartHeight / gridLines) * i; + doc.moveTo(startX, y) + .lineTo(startX + chartWidth, y) + .strokeColor(COLORS.LIGHT_GRAY) + .lineWidth(0.5) + .stroke(); + + const value = maxValue - (valueRange / gridLines) * i; + doc.fontSize(8) + .fillColor(COLORS.TEXT) + .text( + formatCurrency(value), + startX - 45, + y - 4, + { width: 40, align: "right" } + ); + } + + if (data.length > 0 && data[0].values.length > 0) { + const pointSpacing = chartWidth / (data.length - 1 || 1); + + data[0].values.forEach((series, seriesIndex) => { + const color = series.color; + let firstPoint = true; + + data.forEach((point, pointIndex) => { + const value = point.values[seriesIndex]?.value || 0; + const x = startX + pointSpacing * pointIndex; + const y = chartStartY + chartHeight - ((value - minValue) / valueRange) * chartHeight; + + if (firstPoint) { + doc.moveTo(x, y); + firstPoint = false; + } else { + doc.lineTo(x, y); + } + + doc.circle(x, y, 3) + .fillColor(color) + .fill(); + }); + + doc.strokeColor(color) + .lineWidth(2) + .stroke(); + }); + } + + data.forEach((point, index) => { + const x = startX + (chartWidth / (data.length - 1 || 1)) * index; + doc.fontSize(8) + .fillColor(COLORS.TEXT) + .text( + point.label, + x - 30, + chartStartY + chartHeight + 10, + { width: 60, align: "center" } + ); + }); + + if (data.length > 0 && data[0].values.length > 0) { + const legendY = chartStartY + chartHeight + 40; + let legendX = startX; + + data[0].values.forEach((series) => { + doc.rect(legendX, legendY, 12, 12) + .fillColor(series.color) + .fill(); + + doc.fontSize(9) + .fillColor(COLORS.TEXT) + .text(series.name, legendX + 18, legendY, { width: 80 }); + + legendX += 120; + }); + } + + doc.y = chartStartY + chartHeight + 70; +} diff --git a/src/features/reports/infrastructure/services/pdf/pdf-components.ts b/src/features/reports/infrastructure/services/pdf/pdf-components.ts new file mode 100644 index 0000000..7ddbed6 --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-components.ts @@ -0,0 +1,87 @@ +import PDFDocument from "pdfkit"; +import { COLORS, DIMENSIONS, SPACING, checkPageBreak } from "./pdf-utils"; +import { addNewPage } from "./pdf-layout"; + +export function addSectionTitle(doc: typeof PDFDocument, title: string, reportTitle?: string) { + if (checkPageBreak(doc, 40)) { + addNewPage(doc, reportTitle); + } + + doc + .fontSize(16) + .fillColor(COLORS.SECONDARY) + .font("Helvetica-Bold") + .text(title, DIMENSIONS.MARGIN, doc.y); + + doc.moveDown(0.5); + doc.rect(DIMENSIONS.MARGIN, doc.y, 150, 2).fillColor(COLORS.SECONDARY).fill(); + doc.moveDown(1); +} + +export function addMetricCard( + doc: typeof PDFDocument, + label: string, + value: string, + x: number, + y: number, + width: number = 120, + color: string = COLORS.SECONDARY +) { + const height = 60; + + doc.rect(x, y, width, height).fillColor(COLORS.LIGHT_GRAY).fill(); + + doc.rect(x, y, width, 4).fillColor(color).fill(); + + doc + .fontSize(9) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text(label, x + 10, y + 15, { width: width - 20, align: "center" }); + + doc + .fontSize(16) + .fillColor(color) + .font("Helvetica-Bold") + .text(value, x + 10, y + 32, { width: width - 20, align: "center" }); +} + +export function drawProgressBar( + doc: typeof PDFDocument, + x: number, + y: number, + width: number, + progress: number +) { + const height = 12; + const validProgress = Math.min(Math.max(progress, 0), 100); + + doc.rect(x, y, width, height).fillColor(COLORS.LIGHT_GRAY).fill(); + + if (validProgress > 0) { + const progressWidth = (width * validProgress) / 100; + const color = + validProgress >= 100 + ? COLORS.ACCENT + : validProgress >= 70 + ? COLORS.WARNING + : COLORS.SECONDARY; + + doc.rect(x, y, progressWidth, height).fillColor(color).fill(); + } + + doc + .rect(x, y, width, height) + .strokeColor(COLORS.MEDIUM_GRAY) + .lineWidth(1) + .stroke(); + + doc + .fontSize(8) + .fillColor(COLORS.TEXT) + .font("Helvetica-Bold") + .text(`${validProgress.toFixed(1)}%`, x, y + 2, { + width: width, + align: "center", + }); +} diff --git a/src/features/reports/infrastructure/services/pdf/pdf-goal-formatters.ts b/src/features/reports/infrastructure/services/pdf/pdf-goal-formatters.ts new file mode 100644 index 0000000..84a9fc9 --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-goal-formatters.ts @@ -0,0 +1,471 @@ +import PDFDocument from "pdfkit"; +import { + COLORS, + DIMENSIONS, + formatCurrency, + formatDate, + getStatusLabel, + getStatusColor, + checkPageBreak, +} from "./pdf-utils"; +import { + addSectionTitle, + addMetricCard, + drawProgressBar, +} from "./pdf-components"; +import { drawTable } from "./pdf-table"; +import { addNewPage } from "./pdf-layout"; + +export function formatGoalsByStatus(doc: typeof PDFDocument, data: any, reportTitle: string = "Metas por Estado") { + if (!data || !data.goals) { + doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); + return; + } + + addSectionTitle(doc, "Resumen General", reportTitle); + + const cardY = doc.y; + const cardWidth = 110; + const cardSpacing = 15; + + addMetricCard( + doc, + "Total Metas", + data.total?.toString() || "0", + DIMENSIONS.MARGIN, + cardY, + cardWidth, + COLORS.SECONDARY + ); + addMetricCard( + doc, + "Completadas", + data.completed?.toString() || "0", + DIMENSIONS.MARGIN + cardWidth + cardSpacing, + cardY, + cardWidth, + COLORS.ACCENT + ); + addMetricCard( + doc, + "En Progreso", + data.inProgress?.toString() || "0", + DIMENSIONS.MARGIN + 2 * (cardWidth + cardSpacing), + cardY, + cardWidth, + COLORS.WARNING + ); + addMetricCard( + doc, + "Expiradas", + data.expired?.toString() || "0", + DIMENSIONS.MARGIN + 3 * (cardWidth + cardSpacing), + cardY, + cardWidth, + COLORS.DANGER + ); + + doc.y = cardY + 70; + doc.moveDown(1); + + addSectionTitle(doc, "Detalle de Metas", reportTitle); + + if (data.goals.length === 0) { + doc + .fontSize(11) + .fillColor(COLORS.TEXT) + .text("No se encontraron metas para mostrar", DIMENSIONS.MARGIN, doc.y); + return; + } + + data.goals.forEach((goal: any, index: number) => { + if (checkPageBreak(doc, 120)) { + addNewPage(doc, reportTitle); + } + + if (index > 0) { + doc.moveDown(1); + } + + const statusColor = getStatusColor(goal.status); + const statusLabel = getStatusLabel(goal.status); + + doc + .fontSize(13) + .fillColor(COLORS.PRIMARY) + .font("Helvetica-Bold") + .text(goal.name || "Sin nombre", DIMENSIONS.MARGIN, doc.y, { + continued: true, + }) + .fontSize(9) + .fillColor(statusColor) + .text(` [${statusLabel}]`, { continued: false }); + + doc.moveDown(0.5); + + const col1X = DIMENSIONS.MARGIN; + const col2X = DIMENSIONS.MARGIN + 250; + const rowY = doc.y; + + doc + .fontSize(10) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text(`Meta: ${formatCurrency(goal.targetAmount || 0)}`, col1X, rowY) + .text(`Categoría: ${goal.categoryName || "Sin categoría"}`, col2X, rowY); + + doc.moveDown(0.8); + const row2Y = doc.y; + + doc + .text( + `Ahorrado: ${formatCurrency(goal.currentAmount || 0)}`, + col1X, + row2Y + ) + .text(`Fecha límite: ${formatDate(goal.deadline)}`, col2X, row2Y); + + doc.moveDown(1); + + drawProgressBar(doc, DIMENSIONS.MARGIN, doc.y, 450, goal.progress || 0); + + doc.moveDown(1.5); + }); +} + +export function formatGoalsByCategory(doc: typeof PDFDocument, data: any, reportTitle: string = "Metas por Categoría") { + if (!data || !data.categories) { + doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); + return; + } + + addSectionTitle(doc, "Resumen General"); + + const cardY = doc.y; + const cardWidth = 140; + const cardSpacing = 20; + + addMetricCard( + doc, + "Total Categorías", + data.totalCategories?.toString() || "0", + DIMENSIONS.MARGIN, + cardY, + cardWidth, + COLORS.SECONDARY + ); + addMetricCard( + doc, + "Total Metas", + data.totalGoals?.toString() || "0", + DIMENSIONS.MARGIN + cardWidth + cardSpacing, + cardY, + cardWidth, + COLORS.PRIMARY + ); + + doc.y = cardY + 70; + doc.moveDown(1); + + addSectionTitle(doc, "Detalle por Categoría", reportTitle); + + if (data.categories.length === 0) { + doc + .fontSize(11) + .fillColor(COLORS.TEXT) + .text("No se encontraron categorías con metas", DIMENSIONS.MARGIN, doc.y); + return; + } + + data.categories.forEach((category: any, catIndex: number) => { + if (checkPageBreak(doc, 200)) { + addNewPage(doc, reportTitle); + } + + if (catIndex > 0) { + doc.moveDown(1.5); + } + + doc + .fontSize(14) + .fillColor(COLORS.SECONDARY) + .font("Helvetica-Bold") + .text(category.name || "Sin nombre", DIMENSIONS.MARGIN, doc.y); + + doc.moveDown(0.5); + + const col1X = DIMENSIONS.MARGIN; + const col2X = DIMENSIONS.MARGIN + 200; + const col3X = DIMENSIONS.MARGIN + 350; + const statsY = doc.y; + + doc + .fontSize(9) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text(`Metas: ${category.totalGoals || 0}`, col1X, statsY) + .text( + `Total: ${formatCurrency(category.totalAmount || 0)}`, + col2X, + statsY + ) + .text( + `Ahorrado: ${formatCurrency(category.completedAmount || 0)}`, + col3X, + statsY + ); + + doc.moveDown(1); + + drawProgressBar(doc, DIMENSIONS.MARGIN, doc.y, 450, category.progress || 0); + + doc.moveDown(1); + + if (category.goals && category.goals.length > 0) { + const headers = ["Meta", "Objetivo", "Ahorrado", "Progreso", "Estado"]; + const rows = category.goals.map((goal: any) => [ + goal.name || "Sin nombre", + formatCurrency(goal.targetAmount || 0), + formatCurrency(goal.currentAmount || 0), + `${(goal.progress || 0).toFixed(1)}%`, + getStatusLabel(goal.status || "inProgress"), + ]); + + drawTable(doc, headers, rows, [140, 80, 80, 70, 80], reportTitle); + } + + doc.moveDown(0.5); + }); +} + +export function formatContributionsByGoal(doc: typeof PDFDocument, data: any, reportTitle: string = "Contribuciones por Meta") { + if (!data) { + doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); + return; + } + + addSectionTitle(doc, "Información de la Meta", reportTitle); + + doc + .fontSize(14) + .fillColor(COLORS.PRIMARY) + .font("Helvetica-Bold") + .text(data.goalName || "Sin nombre", DIMENSIONS.MARGIN, doc.y); + + doc.moveDown(1); + + const cardY = doc.y; + const cardWidth = 150; + const cardSpacing = 15; + + addMetricCard( + doc, + "Total Contribuciones", + formatCurrency(data.totalContributions || 0), + DIMENSIONS.MARGIN, + cardY, + cardWidth, + COLORS.ACCENT + ); + addMetricCard( + doc, + "Promedio", + formatCurrency(data.averageContribution || 0), + DIMENSIONS.MARGIN + cardWidth + cardSpacing, + cardY, + cardWidth, + COLORS.SECONDARY + ); + addMetricCard( + doc, + "Última Contribución", + data.lastContributionDate ? formatDate(data.lastContributionDate) : "N/A", + DIMENSIONS.MARGIN + 2 * (cardWidth + cardSpacing), + cardY, + cardWidth, + COLORS.PRIMARY + ); + + doc.y = cardY + 70; + doc.moveDown(1); + + addSectionTitle(doc, "Historial de Contribuciones", reportTitle); + + if (!data.contributions || data.contributions.length === 0) { + doc + .fontSize(11) + .fillColor(COLORS.TEXT) + .text("No se encontraron contribuciones", DIMENSIONS.MARGIN, doc.y); + return; + } + + const headers = ["Fecha", "Monto", "ID Transacción"]; + const rows = data.contributions.map((contrib: any) => [ + formatDate(contrib.date), + formatCurrency(contrib.amount || 0), + contrib.transactionId || "N/A", + ]); + + drawTable(doc, headers, rows, [150, 150, 150], reportTitle); +} + +export function formatSavingsComparison(doc: typeof PDFDocument, data: any, reportTitle: string = "Comparación de Ahorro") { + if (!data) { + doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); + return; + } + + addSectionTitle(doc, "Comparación de Ahorro", reportTitle); + + doc + .fontSize(14) + .fillColor(COLORS.PRIMARY) + .font("Helvetica-Bold") + .text(data.goalName || "Sin nombre", DIMENSIONS.MARGIN, doc.y); + + doc.moveDown(1.5); + + if (!data.deviations || data.deviations.length === 0) { + doc + .fontSize(11) + .fillColor(COLORS.TEXT) + .text( + "No hay datos de comparación disponibles", + DIMENSIONS.MARGIN, + doc.y + ); + return; + } + + const headers = ["Fecha", "Planificado", "Real", "Diferencia"]; + const rows = data.deviations.map((dev: any) => { + const diff = dev.difference || 0; + const diffStr = (diff >= 0 ? "+" : "") + formatCurrency(Math.abs(diff)); + return [ + formatDate(dev.date), + formatCurrency(dev.plannedAmount || 0), + formatCurrency(dev.actualAmount || 0), + diffStr, + ]; + }); + + drawTable(doc, headers, rows, [120, 120, 120, 120], reportTitle); +} + +export function formatSavingsSummary(doc: typeof PDFDocument, data: any, reportTitle: string = "Resumen de Ahorro") { + if (!data) { + doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); + return; + } + + addSectionTitle(doc, "Resumen General"); + + const cardY = doc.y; + const cardWidth = 110; + const cardSpacing = 12; + + addMetricCard( + doc, + "Total Metas", + data.totalGoals?.toString() || "0", + DIMENSIONS.MARGIN, + cardY, + cardWidth, + COLORS.SECONDARY + ); + addMetricCard( + doc, + "Completadas", + data.completedGoals?.toString() || "0", + DIMENSIONS.MARGIN + cardWidth + cardSpacing, + cardY, + cardWidth, + COLORS.ACCENT + ); + addMetricCard( + doc, + "En Progreso", + data.inProgressGoals?.toString() || "0", + DIMENSIONS.MARGIN + 2 * (cardWidth + cardSpacing), + cardY, + cardWidth, + COLORS.WARNING + ); + addMetricCard( + doc, + "Expiradas", + data.expiredGoals?.toString() || "0", + DIMENSIONS.MARGIN + 3 * (cardWidth + cardSpacing), + cardY, + cardWidth, + COLORS.DANGER + ); + + doc.y = cardY + 70; + doc.moveDown(1); + + addSectionTitle(doc, "Métricas Financieras", reportTitle); + + const metricsY = doc.y; + + addMetricCard( + doc, + "Meta Total", + formatCurrency(data.totalTargetAmount || 0), + DIMENSIONS.MARGIN, + metricsY, + 150, + COLORS.PRIMARY + ); + addMetricCard( + doc, + "Ahorrado Total", + formatCurrency(data.totalCurrentAmount || 0), + DIMENSIONS.MARGIN + 165, + metricsY, + 150, + COLORS.ACCENT + ); + addMetricCard( + doc, + "Contribución Promedio", + formatCurrency(data.averageContribution || 0), + DIMENSIONS.MARGIN + 330, + metricsY, + 150, + COLORS.SECONDARY + ); + + doc.y = metricsY + 70; + doc.moveDown(1); + + doc + .fontSize(11) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text("Progreso General:", DIMENSIONS.MARGIN, doc.y); + + doc.moveDown(0.5); + drawProgressBar( + doc, + DIMENSIONS.MARGIN, + doc.y, + 450, + data.overallProgress || 0 + ); + doc.moveDown(1.5); + + if (data.categoryBreakdown && data.categoryBreakdown.length > 0) { + addSectionTitle(doc, "Desglose por Categoría", reportTitle); + + const headers = ["Categoría", "Metas", "Monto Total", "Progreso"]; + const rows = data.categoryBreakdown.map((cat: any) => [ + cat.categoryName || "Sin categoría", + cat.totalGoals?.toString() || "0", + formatCurrency(cat.totalAmount || 0), + `${(cat.progress || 0).toFixed(1)}%`, + ]); + + drawTable(doc, headers, rows, [160, 80, 120, 100], reportTitle); + } +} diff --git a/src/features/reports/infrastructure/services/pdf/pdf-layout.ts b/src/features/reports/infrastructure/services/pdf/pdf-layout.ts new file mode 100644 index 0000000..07e5284 --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-layout.ts @@ -0,0 +1,85 @@ +import PDFDocument from "pdfkit"; +import { COLORS, DIMENSIONS } from "./pdf-utils"; + +export function addModernHeader(doc: typeof PDFDocument, reportTitle: string) { + doc + .rect(0, 0, DIMENSIONS.PAGE_WIDTH, DIMENSIONS.HEADER_HEIGHT) + .fillColor(COLORS.PRIMARY) + .fill(); + + doc + .fontSize(24) + .fillColor(COLORS.WHITE) + .font("Helvetica-Bold") + .text("FoppyAI", DIMENSIONS.MARGIN, 20, { align: "left" }); + + doc + .fontSize(10) + .fillColor(COLORS.WHITE) + .font("Helvetica") + .text("Sistema de Gestión Financiera Personal", DIMENSIONS.MARGIN, 48); + + const date = new Date().toLocaleDateString("es-ES", { + day: "2-digit", + month: "long", + year: "numeric", + }); + doc + .fontSize(10) + .text(date, DIMENSIONS.PAGE_WIDTH - DIMENSIONS.MARGIN - 150, 25, { + width: 150, + align: "right", + }); + + doc + .rect(0, DIMENSIONS.HEADER_HEIGHT - 5, DIMENSIONS.PAGE_WIDTH, 5) + .fillColor(COLORS.SECONDARY) + .fill(); + + doc.y = DIMENSIONS.HEADER_HEIGHT + 20; +} + +export function addModernFooter( + doc: typeof PDFDocument, + pageNum: number, + totalPages: number +) { + const footerY = DIMENSIONS.PAGE_HEIGHT - DIMENSIONS.FOOTER_HEIGHT; + + doc + .rect(0, footerY, DIMENSIONS.PAGE_WIDTH, 2) + .fillColor(COLORS.MEDIUM_GRAY) + .fill(); + + doc + .fontSize(8) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text( + `© ${new Date().getFullYear()} FoppyAI. Todos los derechos reservados.`, + DIMENSIONS.MARGIN, + footerY + 15, + { + width: DIMENSIONS.PAGE_WIDTH - DIMENSIONS.MARGIN * 2, + align: "center" + } + ); +} + +export function addReportTitle(doc: typeof PDFDocument, title: string) { + doc + .fontSize(20) + .fillColor(COLORS.PRIMARY) + .font("Helvetica-Bold") + .text(title, DIMENSIONS.MARGIN, doc.y, { align: "left" }); +} + +export function addNewPage(doc: typeof PDFDocument, reportTitle?: string) { + doc.addPage(); + + if (reportTitle) { + addModernHeader(doc, reportTitle); + } else { + doc.y = DIMENSIONS.HEADER_HEIGHT + 20; + } +} diff --git a/src/features/reports/infrastructure/services/pdf/pdf-overview-formatters.ts b/src/features/reports/infrastructure/services/pdf/pdf-overview-formatters.ts new file mode 100644 index 0000000..7f81b64 --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-overview-formatters.ts @@ -0,0 +1,272 @@ +import PDFDocument from "pdfkit"; +import { FinancialOverviewReport } from "../../../domain/entities/report.entity"; +import { + addSectionTitle, + addMetricCard, + drawProgressBar, +} from "./pdf-components"; +import { drawTable } from "./pdf-table"; +import { drawBarChart, drawPieChart } from "./pdf-charts"; +import { + formatCurrency, + formatDate, + COLORS, + DIMENSIONS, + checkPageBreak, +} from "./pdf-utils"; +import { addNewPage } from "./pdf-layout"; + +export function formatFinancialOverview( + doc: typeof PDFDocument.prototype, + data: FinancialOverviewReport, + reportTitle: string = "Vista General Financiera" +): void { + if (!data) { + doc.fontSize(12).fillColor(COLORS.TEXT).text("No hay datos disponibles"); + return; + } + + addSectionTitle(doc, "Resumen Financiero General", reportTitle); + + doc + .fontSize(10) + .fillColor(COLORS.TEXT) + .text( + `Período: ${formatDate(data.period?.startDate)} - ${formatDate( + data.period?.endDate + )}`, + DIMENSIONS.MARGIN, + doc.y + ); + doc.moveDown(1); + + const cardY = doc.y; + const cardWidth = 110; + const cardSpacing = 15; + + addMetricCard( + doc, + "Total Ingresos", + formatCurrency(data.summary?.totalIncome || 0), + DIMENSIONS.MARGIN, + cardY, + cardWidth, + COLORS.ACCENT + ); + addMetricCard( + doc, + "Total Gastos", + formatCurrency(data.summary?.totalExpense || 0), + DIMENSIONS.MARGIN + cardWidth + cardSpacing, + cardY, + cardWidth, + COLORS.DANGER + ); + addMetricCard( + doc, + "Balance Neto", + formatCurrency(data.summary?.netBalance || 0), + DIMENSIONS.MARGIN + 2 * (cardWidth + cardSpacing), + cardY, + cardWidth, + COLORS.SECONDARY + ); + addMetricCard( + doc, + "Tasa de Ahorro", + `${(data.summary?.savingsRate || 0).toFixed(1)}%`, + DIMENSIONS.MARGIN + 3 * (cardWidth + cardSpacing), + cardY, + cardWidth, + COLORS.PRIMARY + ); + + doc.y = cardY + 70; + doc.moveDown(1); + + if ( + (data.summary?.totalIncome || 0) > 0 || + (data.summary?.totalExpense || 0) > 0 + ) { + addSectionTitle(doc, "Ingresos vs Gastos", reportTitle); + + const chartData = [ + { + label: "Ingresos", + value: data.summary?.totalIncome || 0, + color: COLORS.ACCENT, + }, + { + label: "Gastos", + value: data.summary?.totalExpense || 0, + color: COLORS.DANGER, + }, + ]; + + drawBarChart(doc, chartData, "Ingresos vs Gastos", 450, 150); + doc.moveDown(2); + } + + if (checkPageBreak(doc, 200)) { + addNewPage(doc, reportTitle); + } + + addSectionTitle(doc, "Resumen de Metas", reportTitle); + + const goalCardY = doc.y; + const goalCardWidth = 120; + const goalCardSpacing = 15; + + addMetricCard( + doc, + "Total Metas", + (data.goals?.total || 0).toString(), + DIMENSIONS.MARGIN, + goalCardY, + goalCardWidth, + COLORS.SECONDARY + ); + addMetricCard( + doc, + "Completadas", + (data.goals?.completed || 0).toString(), + DIMENSIONS.MARGIN + goalCardWidth + goalCardSpacing, + goalCardY, + goalCardWidth, + COLORS.ACCENT + ); + addMetricCard( + doc, + "En Progreso", + (data.goals?.inProgress || 0).toString(), + DIMENSIONS.MARGIN + 2 * (goalCardWidth + goalCardSpacing), + goalCardY, + goalCardWidth, + COLORS.WARNING + ); + + doc.y = goalCardY + 70; + doc.moveDown(0.8); + + doc + .fontSize(10) + .fillColor(COLORS.TEXT) + .text( + `Ahorrado: ${formatCurrency( + data.goals?.totalSaved || 0 + )} / Meta: ${formatCurrency(data.goals?.totalTarget || 0)}`, + DIMENSIONS.MARGIN, + doc.y + ); + doc.moveDown(0.5); + + drawProgressBar( + doc, + DIMENSIONS.MARGIN, + doc.y, + 450, + data.goals?.overallProgress || 0 + ); + doc.moveDown(1); + + addSectionTitle(doc, "Resumen de Presupuestos", reportTitle); + + const budgetCardY = doc.y; + const budgetCardWidth = 120; + const budgetCardSpacing = 15; + + addMetricCard( + doc, + "Total Presupuestos", + (data.budgets?.total || 0).toString(), + DIMENSIONS.MARGIN, + budgetCardY, + budgetCardWidth, + COLORS.SECONDARY + ); + addMetricCard( + doc, + "Excedidos", + (data.budgets?.exceeded || 0).toString(), + DIMENSIONS.MARGIN + budgetCardWidth + budgetCardSpacing, + budgetCardY, + budgetCardWidth, + COLORS.DANGER + ); + addMetricCard( + doc, + "Uso Promedio", + `${(data.budgets?.averageUtilization || 0).toFixed(1)}%`, + DIMENSIONS.MARGIN + 2 * (budgetCardWidth + budgetCardSpacing), + budgetCardY, + budgetCardWidth, + COLORS.WARNING + ); + + doc.y = budgetCardY + 70; + doc.moveDown(1); + + if (checkPageBreak(doc, 200)) { + addNewPage(doc, reportTitle); + } + + if (data.topCategories?.expenses && data.topCategories.expenses.length > 0) { + addSectionTitle(doc, "Categorías de Gastos Principales", reportTitle); + + const expensePieData = data.topCategories.expenses.map((cat) => ({ + label: cat.name || "Sin nombre", + value: cat.amount || 0, + percentage: cat.percentage || 0, + })); + + drawPieChart(doc, expensePieData, "Categorías de Gastos Principales", 180); + doc.moveDown(2); + } + + if (checkPageBreak(doc, 200)) { + addNewPage(doc, reportTitle); + } + + if (data.topCategories?.expenses && data.topCategories.expenses.length > 0) { + addSectionTitle(doc, "Desglose de Gastos Principales", reportTitle); + + const expenseData = data.topCategories.expenses.map((cat) => [ + cat.name || "Sin nombre", + formatCurrency(cat.amount || 0), + `${(cat.percentage || 0).toFixed(1)}%`, + ]); + + const columnWidths = [150, 120, 100]; + drawTable( + doc, + ["Categoría", "Monto", "Porcentaje"], + expenseData, + columnWidths, + reportTitle + ); + doc.moveDown(1); + } + + if (data.topCategories?.income && data.topCategories.income.length > 0) { + if (checkPageBreak(doc, 200)) { + addNewPage(doc, reportTitle); + } + + addSectionTitle(doc, "Desglose de Ingresos Principales", reportTitle); + + const incomeData = data.topCategories.income.map((cat) => [ + cat.name || "Sin nombre", + formatCurrency(cat.amount || 0), + `${(cat.percentage || 0).toFixed(1)}%`, + ]); + + const columnWidths = [150, 120, 100]; + drawTable( + doc, + ["Categoría", "Monto", "Porcentaje"], + incomeData, + columnWidths, + reportTitle + ); + } +} diff --git a/src/features/reports/infrastructure/services/pdf/pdf-table.ts b/src/features/reports/infrastructure/services/pdf/pdf-table.ts new file mode 100644 index 0000000..e993f96 --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-table.ts @@ -0,0 +1,85 @@ +import PDFDocument from "pdfkit"; +import { COLORS, DIMENSIONS, TABLE, checkPageBreak } from "./pdf-utils"; +import { addNewPage } from "./pdf-layout"; + +export function drawTable( + doc: typeof PDFDocument, + headers: string[], + rows: string[][], + columnWidths: number[], + reportTitle?: string +) { + const maxWidth = DIMENSIONS.PAGE_WIDTH - DIMENSIONS.MARGIN * 2; + const totalWidth = columnWidths.reduce((a, b) => a + b, 0); + + const startX = totalWidth < maxWidth + ? DIMENSIONS.MARGIN + (maxWidth - totalWidth) / 2 + : DIMENSIONS.MARGIN; + + const tableHeight = TABLE.HEADER_HEIGHT + rows.length * TABLE.ROW_HEIGHT; + + if (checkPageBreak(doc, tableHeight)) { + addNewPage(doc, reportTitle); + } + + let currentY = doc.y; + + doc.rect(startX, currentY, totalWidth, TABLE.HEADER_HEIGHT) + .fillColor(TABLE.HEADER_COLOR) + .fill(); + + let x = startX; + headers.forEach((header, i) => { + doc.fontSize(10) + .fillColor(COLORS.WHITE) + .font("Helvetica-Bold") + .text( + header, + x + TABLE.CELL_PADDING, + currentY + TABLE.CELL_PADDING, + { + width: columnWidths[i] - 2 * TABLE.CELL_PADDING, + align: "center", + } + ); + x += columnWidths[i]; + }); + + currentY += TABLE.HEADER_HEIGHT; + + rows.forEach((row, rowIndex) => { + const fillColor = rowIndex % 2 === 0 ? COLORS.WHITE : COLORS.LIGHT_GRAY; + doc.rect(startX, currentY, totalWidth, TABLE.ROW_HEIGHT) + .fillColor(fillColor) + .fill(); + + doc.rect(startX, currentY, totalWidth, TABLE.ROW_HEIGHT) + .strokeColor(TABLE.BORDER_COLOR) + .lineWidth(0.5) + .stroke(); + + x = startX; + row.forEach((cell, colIndex) => { + const isNumeric = /^[\$\d,.-]+%?$/.test(cell) || cell === "N/A"; + const align = isNumeric && colIndex > 0 ? "right" : "left"; + + doc.fontSize(9) + .fillColor(COLORS.TEXT) + .font("Helvetica") + .text( + cell, + x + TABLE.CELL_PADDING, + currentY + TABLE.CELL_PADDING, + { + width: columnWidths[colIndex] - 2 * TABLE.CELL_PADDING, + align: align, + } + ); + x += columnWidths[colIndex]; + }); + + currentY += TABLE.ROW_HEIGHT; + }); + + doc.y = currentY + 20; +} diff --git a/src/features/reports/infrastructure/services/pdf/pdf-transaction-formatters.ts b/src/features/reports/infrastructure/services/pdf/pdf-transaction-formatters.ts new file mode 100644 index 0000000..afbf682 --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-transaction-formatters.ts @@ -0,0 +1,307 @@ +import PDFDocument from "pdfkit"; +import { + TransactionsSummaryReport, + ExpensesByCategoryReport, + MonthlyTrendReport, +} from "../../../domain/entities/report.entity"; +import { addMetricCard } from "./pdf-components"; +import { drawTable } from "./pdf-table"; +import { drawBarChart, drawPieChart, drawLineChart } from "./pdf-charts"; +import { formatCurrency, formatDate } from "./pdf-utils"; +import { addNewPage } from "./pdf-layout"; + +export function formatTransactionsSummary( + doc: typeof PDFDocument.prototype, + data: TransactionsSummaryReport, + reportTitle: string = "Resumen de Transacciones" +): void { + const pageWidth = doc.page.width; + const margin = 50; + const contentWidth = pageWidth - margin * 2; + const cardWidth = (contentWidth - 20) / 2; + + const row1Y = doc.y; + addMetricCard( + doc, + "Total Ingresos", + formatCurrency(data.totalIncome), + margin, + row1Y, + cardWidth, + "#10b981" + ); + addMetricCard( + doc, + "Total Gastos", + formatCurrency(data.totalExpense), + margin + cardWidth + 20, + row1Y, + cardWidth, + "#ef4444" + ); + + doc.y = row1Y + 80; + + const row2Y = doc.y; + addMetricCard( + doc, + "Balance Neto", + formatCurrency(data.netBalance), + margin, + row2Y, + cardWidth, + "#3b82f6" + ); + addMetricCard( + doc, + "Transacciones", + `${data.transactionCount}`, + margin + cardWidth + 20, + row2Y, + cardWidth, + "#8b5cf6" + ); + + doc.y = row2Y + 80; + + if (data.totalIncome > 0 || data.totalExpense > 0) { + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Ingresos vs Gastos", margin, doc.y); + doc.moveDown(1); + + const chartData = [ + { label: "Ingresos", value: data.totalIncome, color: "#10b981" }, + { label: "Gastos", value: data.totalExpense, color: "#ef4444" }, + ]; + + drawBarChart(doc, chartData, "Ingresos vs Gastos", contentWidth, 150); + doc.moveDown(2); + } + + if (data.topIncomeCategory || data.topExpenseCategory) { + doc.fontSize(14).fillColor("#1f2937").text("Categorías Principales", margin, doc.y); + doc.moveDown(1); + + const topCategoriesData = []; + if (data.topIncomeCategory) { + topCategoriesData.push([ + "Mayor Ingreso", + data.topIncomeCategory.name, + formatCurrency(data.topIncomeCategory.amount), + ]); + } + if (data.topExpenseCategory) { + topCategoriesData.push([ + "Mayor Gasto", + data.topExpenseCategory.name, + formatCurrency(data.topExpenseCategory.amount), + ]); + } + + const columnWidths = [120, 200, 150]; + drawTable( + doc, + ["Tipo", "Categoría", "Monto"], + topCategoriesData, + columnWidths, + reportTitle + ); + doc.moveDown(1); + } + + if (data.transactions.length > 0) { + if (doc.y > 600) addNewPage(doc, reportTitle); + + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Detalle de Transacciones", margin, doc.y); + doc.moveDown(1); + + const transactionsData = data.transactions + .slice(0, 20) + .map((t) => [ + formatDate(t.date), + t.type === "INCOME" ? "Ingreso" : "Gasto", + t.category || "N/A", + formatCurrency(t.amount), + ]); + + const columnWidths = [100, 80, 150, 120]; + drawTable( + doc, + ["Fecha", "Tipo", "Categoría", "Monto"], + transactionsData, + columnWidths, + reportTitle + ); + } +} + +export function formatExpensesByCategory( + doc: typeof PDFDocument.prototype, + data: ExpensesByCategoryReport, + reportTitle: string = "Gastos por Categoría" +): void { + const pageWidth = doc.page.width; + const margin = 50; + const contentWidth = pageWidth - margin * 2; + const cardWidth = (contentWidth - 20) / 2; + + const rowY = doc.y; + addMetricCard( + doc, + "Total Gastos", + formatCurrency(data.totalExpenses), + margin, + rowY, + cardWidth, + "#ef4444" + ); + addMetricCard( + doc, + "Categorías", + `${data.categoryCount}`, + margin + cardWidth + 20, + rowY, + cardWidth, + "#3b82f6" + ); + + doc.y = rowY + 80; + + if (data.categories.length > 0) { + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Distribución de Gastos", margin, doc.y); + doc.moveDown(1); + + const pieData = data.categories.slice(0, 10).map((cat) => ({ + label: cat.name, + value: cat.amount, + percentage: cat.percentage, + })); + + drawPieChart(doc, pieData, "Distribución de Gastos", 200); + doc.moveDown(2); + + if (doc.y > 600) addNewPage(doc, reportTitle); + + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Desglose por Categoría", margin, doc.y); + doc.moveDown(1); + + const categoryData = data.categories.map((cat) => [ + cat.name, + formatCurrency(cat.amount), + `${cat.percentage.toFixed(1)}%`, + `${cat.transactionCount}`, + ]); + + const columnWidths = [150, 120, 100, 100]; + drawTable( + doc, + ["Categoría", "Monto", "Porcentaje", "Transacciones"], + categoryData, + columnWidths, + reportTitle + ); + } +} + +export function formatMonthlyTrend( + doc: typeof PDFDocument.prototype, + data: MonthlyTrendReport, + reportTitle: string = "Tendencia Mensual" +): void { + const pageWidth = doc.page.width; + const margin = 50; + const contentWidth = pageWidth - margin * 2; + const cardWidth = (contentWidth - 30) / 3; + + const rowY = doc.y; + addMetricCard( + doc, + "Ingreso Mensual Promedio", + formatCurrency(data.averageMonthlyIncome), + margin, + rowY, + cardWidth, + "#10b981" + ); + addMetricCard( + doc, + "Gasto Mensual Promedio", + formatCurrency(data.averageMonthlyExpense), + margin + cardWidth + 15, + rowY, + cardWidth, + "#ef4444" + ); + addMetricCard( + doc, + "Tendencia", + data.trend === "increasing" ? "CRECIENTE" : data.trend === "decreasing" ? "DECRECIENTE" : "ESTABLE", + margin + (cardWidth + 15) * 2, + rowY, + cardWidth, + "#3b82f6" + ); + + doc.y = rowY + 80; + + if (data.months.length > 0) { + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Tendencia Financiera Mensual", margin, doc.y); + doc.moveDown(1); + + const formattedLineData = data.months.map((m) => ({ + label: m.month, + values: [ + { name: "Ingresos", value: m.income, color: "#10b981" }, + { name: "Gastos", value: m.expense, color: "#ef4444" }, + { name: "Balance", value: m.balance, color: "#3b82f6" }, + ], + })); + drawLineChart( + doc, + formattedLineData, + "Tendencia Financiera Mensual", + contentWidth, + 180 + ); + doc.moveDown(2); + + if (doc.y > 600) addNewPage(doc, reportTitle); + + doc + .fontSize(14) + .fillColor("#1f2937") + .text("Detalle Mensual", margin, doc.y); + doc.moveDown(1); + + const monthData = data.months.map((m) => [ + m.month, + formatCurrency(m.income), + formatCurrency(m.expense), + formatCurrency(m.balance), + `${m.transactionCount}`, + ]); + + const columnWidths = [100, 120, 120, 120, 100]; + drawTable( + doc, + ["Mes", "Ingresos", "Gastos", "Balance", "Transacciones"], + monthData, + columnWidths, + reportTitle + ); + } +} diff --git a/src/features/reports/infrastructure/services/pdf/pdf-utils.ts b/src/features/reports/infrastructure/services/pdf/pdf-utils.ts new file mode 100644 index 0000000..3b034c3 --- /dev/null +++ b/src/features/reports/infrastructure/services/pdf/pdf-utils.ts @@ -0,0 +1,95 @@ +import PDFDocument from "pdfkit"; + +export const COLORS = { + PRIMARY: "#1e3a8a", + SECONDARY: "#3b82f6", + ACCENT: "#10b981", + WARNING: "#f59e0b", + DANGER: "#ef4444", + TEXT: "#1f2937", + LIGHT_GRAY: "#f3f4f6", + MEDIUM_GRAY: "#e5e7eb", + WHITE: "#ffffff", +} as const; + +export const DIMENSIONS = { + PAGE_WIDTH: 595.28, + PAGE_HEIGHT: 841.89, + MARGIN: 50, + HEADER_HEIGHT: 80, + FOOTER_HEIGHT: 40, +} as const; + +export const SPACING = { + TITLE: 25, + SECTION: 20, + ITEM: 12, +} as const; + +export const TABLE = { + HEADER_COLOR: COLORS.PRIMARY, + BORDER_COLOR: COLORS.MEDIUM_GRAY, + ROW_HEIGHT: 30, + HEADER_HEIGHT: 35, + CELL_PADDING: 8, +} as const; + +export function formatCurrency(amount: number): string { + return `$${amount.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`; +} + +export function formatDate(date: Date | string): string { + const d = typeof date === "string" ? new Date(date) : date; + return d.toLocaleDateString("es-ES", { + day: "2-digit", + month: "short", + year: "numeric", + }); +} + +export function formatMonth(date: Date | string): string { + const d = typeof date === "string" ? new Date(date) : date; + return d.toLocaleDateString("es-ES", { + month: "short", + year: "numeric", + }); +} + +export function getStatusColor(status: string): string { + const colors: Record = { + completed: COLORS.ACCENT, + expired: COLORS.DANGER, + inProgress: COLORS.WARNING, + active: COLORS.ACCENT, + paid: COLORS.ACCENT, + overdue: COLORS.DANGER, + exceeded: COLORS.DANGER, + warning: COLORS.WARNING, + good: COLORS.ACCENT, + }; + return colors[status] || COLORS.TEXT; +} + +export function getStatusLabel(status: string): string { + const labels: Record = { + completed: "Completada", + expired: "Expirada", + inProgress: "En Progreso", + active: "Activa", + paid: "Pagada", + overdue: "Vencida", + exceeded: "Excedido", + warning: "Alerta", + good: "Bueno", + }; + return labels[status] || status; +} + +export function checkPageBreak( + doc: typeof PDFDocument, + requiredHeight: number +): boolean { + const currentY = doc.y; + const footerSpace = DIMENSIONS.FOOTER_HEIGHT + 20; + return currentY + requiredHeight > DIMENSIONS.PAGE_HEIGHT - footerSpace; +} diff --git a/src/features/reports/tests/report.service.test.ts b/src/features/reports/tests/report.service.test.ts new file mode 100644 index 0000000..9c4899e --- /dev/null +++ b/src/features/reports/tests/report.service.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ReportServiceImpl } from '../application/services/report.service'; +import { ReportType, ReportFormat } from '../domain/entities/report.entity'; + +describe('ReportService', () => { + let reportService: ReportServiceImpl; + let reportRepositoryMock: any; + let goalRepositoryMock: any; + let goalContributionRepositoryMock: any; + let budgetRepositoryMock: any; + let transactionRepositoryMock: any; + let excelServiceMock: any; + let csvServiceMock: any; + + beforeEach(() => { + reportRepositoryMock = { + save: vi.fn(), + findById: vi.fn(), + delete: vi.fn(), + deleteExpired: vi.fn(), + }; + goalRepositoryMock = { + findByFilters: vi.fn(), + }; + goalContributionRepositoryMock = {}; + budgetRepositoryMock = { + findByUserId: vi.fn(), + }; + transactionRepositoryMock = { + findByFilters: vi.fn(), + getMonthlyBalance: vi.fn(), + getCategoryTotals: vi.fn(), + getMonthlyTrends: vi.fn(), + }; + excelServiceMock = {}; + csvServiceMock = {}; + + (ReportServiceImpl as any).instance = null; + reportService = ReportServiceImpl.getInstance( + reportRepositoryMock, + goalRepositoryMock, + goalContributionRepositoryMock, + budgetRepositoryMock, + transactionRepositoryMock, + excelServiceMock, + csvServiceMock + ); + }); + + describe('generateReport', () => { + it('should generate GOALS_BY_STATUS report', async () => { + const filters = { userId: '1' }; + (goalRepositoryMock.findByFilters as any).mockResolvedValue([]); + (reportRepositoryMock.save as any).mockImplementation((report: any) => Promise.resolve(report)); + + const report = await reportService.generateReport( + ReportType.GOALS_BY_STATUS, + ReportFormat.JSON, + filters + ); + + expect(report).toBeDefined(); + expect(report.type).toBe(ReportType.GOALS_BY_STATUS); + expect(report.data).toEqual({ + completed: 0, + expired: 0, + inProgress: 0, + total: 0, + goals: [], + }); + expect(reportRepositoryMock.save).toHaveBeenCalled(); + }); + + it('should generate TRANSACTIONS_SUMMARY report', async () => { + const filters = { userId: '1' }; + (transactionRepositoryMock.findByFilters as any).mockResolvedValue([]); + (reportRepositoryMock.save as any).mockImplementation((report: any) => Promise.resolve(report)); + + const report = await reportService.generateReport( + ReportType.TRANSACTIONS_SUMMARY, + ReportFormat.JSON, + filters + ); + + expect(report).toBeDefined(); + expect(report.type).toBe(ReportType.TRANSACTIONS_SUMMARY); + expect(report.data).toEqual({ + totalIncome: 0, + totalExpense: 0, + netBalance: 0, + transactionCount: 0, + incomeCount: 0, + expenseCount: 0, + averageIncome: 0, + averageExpense: 0, + transactions: [], + }); + }); + + it('should throw error for unsupported report type', async () => { + const filters = { userId: '1' }; + await expect( + reportService.generateReport('UNSUPPORTED' as any, ReportFormat.JSON, filters) + ).rejects.toThrow('Unsupported report type: UNSUPPORTED'); + }); + }); + + describe('getReport', () => { + it('should return report if found', async () => { + const mockReport = { id: '1', type: ReportType.GOALS_BY_STATUS }; + (reportRepositoryMock.findById as any).mockResolvedValue(mockReport); + + const report = await reportService.getReport('1'); + + expect(report).toEqual(mockReport); + }); + + it('should throw error if report not found', async () => { + (reportRepositoryMock.findById as any).mockResolvedValue(null); + + await expect(reportService.getReport('1')).rejects.toThrow('Report not found'); + }); + }); +}); diff --git a/src/features/scheduled-transactions/tests/scheduled-transaction.service.test.ts b/src/features/scheduled-transactions/tests/scheduled-transaction.service.test.ts new file mode 100644 index 0000000..b1552bd --- /dev/null +++ b/src/features/scheduled-transactions/tests/scheduled-transaction.service.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ScheduledTransactionService } from '../application/services/scheduled-transaction.service'; +import { IScheduledTransactionRepository } from '../domain/ports/scheduled-transaction-repository.port'; +import { ITransactionRepository } from '@/transactions/domain/ports/transaction-repository.port'; +import { ScheduledTransactionUtilsService } from '../application/services/scheduled-transaction-utils.service'; +import * as HttpStatusCodes from 'stoker/http-status-codes'; +import { ScheduledTransactionApiAdapter } from '../infrastructure/adapters/scheduled-transaction-api.adapter'; + +// Mocks +vi.mock('../infrastructure/adapters/scheduled-transaction-api.adapter', () => ({ + ScheduledTransactionApiAdapter: { + toApiResponseList: vi.fn((data) => data), + toApiResponse: vi.fn((data) => data), + }, +})); + +describe('ScheduledTransactionService', () => { + let scheduledTransactionService: ScheduledTransactionService; + let scheduledTransactionRepositoryMock: IScheduledTransactionRepository; + let transactionRepositoryMock: ITransactionRepository; + let scheduledTransactionUtilsMock: ScheduledTransactionUtilsService; + + beforeEach(() => { + scheduledTransactionRepositoryMock = { + findAll: vi.fn(), + findById: vi.fn(), + findByUserId: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + findPendingExecutions: vi.fn(), + } as any; + + transactionRepositoryMock = { + create: vi.fn(), + } as any; + + scheduledTransactionUtilsMock = { + validateUser: vi.fn(), + validatePaymentMethod: vi.fn(), + shouldExecute: vi.fn(), + } as any; + + (ScheduledTransactionService as any).instance = null; + scheduledTransactionService = ScheduledTransactionService.getInstance( + scheduledTransactionRepositoryMock, + transactionRepositoryMock, + scheduledTransactionUtilsMock + ); + }); + + describe('getAll', () => { + it('should return all scheduled transactions', async () => { + (scheduledTransactionRepositoryMock.findAll as any).mockResolvedValue([]); + const c = { + json: vi.fn(), + }; + + await scheduledTransactionService.getAll(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Scheduled transactions retrieved successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('getById', () => { + it('should return 404 if scheduled transaction not found', async () => { + (scheduledTransactionRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await scheduledTransactionService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Scheduled transaction not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return scheduled transaction if found', async () => { + (scheduledTransactionRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await scheduledTransactionService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Scheduled transaction retrieved successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('getByUserId', () => { + it('should return 404 if user not found', async () => { + (scheduledTransactionUtilsMock.validateUser as any).mockResolvedValue({ isValid: false }); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await scheduledTransactionService.getByUserId(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return user scheduled transactions if user found', async () => { + (scheduledTransactionUtilsMock.validateUser as any).mockResolvedValue({ isValid: true }); + (scheduledTransactionRepositoryMock.findByUserId as any).mockResolvedValue([]); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await scheduledTransactionService.getByUserId(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'User scheduled transactions retrieved successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('create', () => { + it('should return 404 if user not found', async () => { + (scheduledTransactionUtilsMock.validateUser as any).mockResolvedValue({ isValid: false }); + const c = { + req: { valid: vi.fn().mockReturnValue({ user_id: 1 }) }, + json: vi.fn(), + }; + + await scheduledTransactionService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should create scheduled transaction successfully', async () => { + (scheduledTransactionUtilsMock.validateUser as any).mockResolvedValue({ isValid: true }); + (scheduledTransactionRepositoryMock.create as any).mockResolvedValue({ id: 1 }); + const c = { + req: { valid: vi.fn().mockReturnValue({ user_id: 1, name: 'Test', amount: 100, next_execution_date: '2023-01-01' }) }, + json: vi.fn(), + }; + + await scheduledTransactionService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Scheduled transaction created successfully' }), + HttpStatusCodes.CREATED + ); + }); + }); + + describe('update', () => { + it('should return 404 if scheduled transaction not found', async () => { + (scheduledTransactionRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1'), valid: vi.fn().mockReturnValue({}) }, + json: vi.fn(), + }; + + await scheduledTransactionService.update(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Scheduled transaction not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should update scheduled transaction successfully', async () => { + (scheduledTransactionRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + (scheduledTransactionRepositoryMock.update as any).mockResolvedValue({ id: 1 }); + const c = { + req: { param: vi.fn().mockReturnValue('1'), valid: vi.fn().mockReturnValue({ name: 'New Name' }) }, + json: vi.fn(), + }; + + await scheduledTransactionService.update(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Scheduled transaction updated successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('delete', () => { + it('should return 404 if scheduled transaction not found', async () => { + (scheduledTransactionRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await scheduledTransactionService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Scheduled transaction not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should delete scheduled transaction successfully', async () => { + (scheduledTransactionRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + (scheduledTransactionRepositoryMock.delete as any).mockResolvedValue(true); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await scheduledTransactionService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Scheduled transaction deleted successfully' }), + HttpStatusCodes.OK + ); + }); + }); + + describe('findPendingExecutions', () => { + it('should execute pending transactions', async () => { + const scheduled = { id: 1, userId: 1, amount: 100, nextExecutionDate: new Date(), frequency: 'MONTHLY' }; + (scheduledTransactionRepositoryMock.findPendingExecutions as any).mockResolvedValue([scheduled]); + (scheduledTransactionUtilsMock.shouldExecute as any).mockResolvedValue(true); + (transactionRepositoryMock.create as any).mockResolvedValue({ id: 1 }); + + const c = { + json: vi.fn(), + }; + + await scheduledTransactionService.findPendingExecutions(c as any); + + expect(transactionRepositoryMock.create).toHaveBeenCalled(); + expect(scheduledTransactionRepositoryMock.update).toHaveBeenCalled(); + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: true, message: 'Pending transactions executed successfully' }), + HttpStatusCodes.OK + ); + }); + }); +}); diff --git a/src/features/subscriptions/application/dtos/subscription.dto.ts b/src/features/subscriptions/application/dtos/subscription.dto.ts new file mode 100644 index 0000000..f809a19 --- /dev/null +++ b/src/features/subscriptions/application/dtos/subscription.dto.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const setPlanSchema = z.object({ + planId: z.number().int().positive(), + frequency: z.string().min(1), + userId: z.number().int().positive(), +}); + +export type SetPlanDTO = z.infer; + diff --git a/src/features/subscriptions/application/services/subscription.service.ts b/src/features/subscriptions/application/services/subscription.service.ts new file mode 100644 index 0000000..fe5e625 --- /dev/null +++ b/src/features/subscriptions/application/services/subscription.service.ts @@ -0,0 +1,152 @@ +import { createHandler } from "@/core/infrastructure/lib/handler.wrapper,"; +import * as HttpStatusCodes from "stoker/http-status-codes"; +import { IPlanRepository } from "@/subscriptions/domain/ports/plan-repository.port"; +import { ISubscriptionRepository } from "@/subscriptions/domain/ports/subscription-repository.port"; +import { + ListPlansRoute, + SetPlanRoute, + CancelPlanRoute, + GetUserSubscriptionRoute, +} from "@/subscriptions/infrastructure/controllers/subscription.routes"; +import { SubscriptionApiAdapter } from "@/subscriptions/infrastructure/adapters/subscription-api.adapter"; +import { IUserRepository } from "@/users/domain/ports/user-repository.port"; + +export class SubscriptionService { + private static instance: SubscriptionService; + + constructor( + private readonly planRepository: IPlanRepository, + private readonly subscriptionRepository: ISubscriptionRepository, + private readonly userRepository: IUserRepository + ) {} + + public static getInstance( + planRepository: IPlanRepository, + subscriptionRepository: ISubscriptionRepository, + userRepository: IUserRepository + ): SubscriptionService { + if (!SubscriptionService.instance) { + SubscriptionService.instance = new SubscriptionService( + planRepository, + subscriptionRepository, + userRepository + ); + } + return SubscriptionService.instance; + } + + listPlans = createHandler(async (c) => { + const plans = await this.planRepository.findAll(); + const visiblePlans = plans.filter((p) => p.name !== "Plan Demo"); + return c.json( + { + success: true, + data: SubscriptionApiAdapter.planToApiResponseList(visiblePlans), + message: "Plans retrieved successfully", + }, + HttpStatusCodes.OK + ); + }); + + setPlan = createHandler(async (c) => { + const data = c.req.valid("json"); + + const user = await this.userRepository.findById(data.userId); + if (!user) { + return c.json( + { + success: false, + data: null, + message: "User not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + const plan = await this.planRepository.findById(data.planId); + if (!plan) { + return c.json( + { + success: false, + data: null, + message: "Plan not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + const startDate = new Date(); + const endDate = new Date(); + endDate.setDate(endDate.getDate() + plan.durationDays); + + const subscription = await this.subscriptionRepository.create({ + userId: data.userId, + planId: data.planId, + frequency: data.frequency, + startDate, + endDate, + active: true, + }); + + return c.json( + { + success: true, + data: SubscriptionApiAdapter.toApiResponse(subscription), + message: "Plan set successfully", + }, + HttpStatusCodes.CREATED + ); + }); + + cancelPlan = createHandler(async (c) => { + const subscriptionId = c.req.param("id"); + + const subscription = await this.subscriptionRepository.findById( + Number(subscriptionId) + ); + if (!subscription) { + return c.json( + { + success: false, + data: null, + message: "Subscription not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + const cancelled = await this.subscriptionRepository.cancel( + subscription.id, + subscription.endDate + ); + + return c.json( + { + success: true, + data: SubscriptionApiAdapter.toApiResponse(cancelled), + message: "Plan cancelled successfully", + }, + HttpStatusCodes.OK + ); + }); + + getUserSubscription = createHandler(async (c) => { + const userId = c.req.param("userId"); + + const subscription = await this.subscriptionRepository.findByUserId( + Number(userId) + ); + + return c.json( + { + success: true, + data: subscription + ? SubscriptionApiAdapter.toApiResponse(subscription) + : null, + message: "User subscription retrieved successfully", + }, + HttpStatusCodes.OK + ); + }); +} + diff --git a/src/features/subscriptions/domain/entities/IPlan.ts b/src/features/subscriptions/domain/entities/IPlan.ts new file mode 100644 index 0000000..89ac463 --- /dev/null +++ b/src/features/subscriptions/domain/entities/IPlan.ts @@ -0,0 +1,12 @@ +export interface IPlan { + id: number; + name: string; + durationDays: number; + price: number; + frequency: string; + description: string | null; + features: string[] | null; + createdAt: Date; + updatedAt: Date; +} + diff --git a/src/features/subscriptions/domain/entities/ISubscription.ts b/src/features/subscriptions/domain/entities/ISubscription.ts new file mode 100644 index 0000000..ab2e2ee --- /dev/null +++ b/src/features/subscriptions/domain/entities/ISubscription.ts @@ -0,0 +1,16 @@ +import { IPlan } from "./IPlan"; + +export interface ISubscription { + id: number; + userId: number; + planId: number; + plan?: IPlan | null; + frequency: string; + startDate: Date; + endDate: Date; + retirementDate?: Date | null; + active: boolean; + createdAt: Date; + updatedAt: Date; +} + diff --git a/src/features/subscriptions/domain/ports/plan-repository.port.ts b/src/features/subscriptions/domain/ports/plan-repository.port.ts new file mode 100644 index 0000000..62feac9 --- /dev/null +++ b/src/features/subscriptions/domain/ports/plan-repository.port.ts @@ -0,0 +1,8 @@ +import { IPlan } from "../entities/IPlan"; + +export interface IPlanRepository { + findAll(): Promise; + findById(id: number): Promise; + findByName(name: string): Promise; +} + diff --git a/src/features/subscriptions/domain/ports/subscription-repository.port.ts b/src/features/subscriptions/domain/ports/subscription-repository.port.ts new file mode 100644 index 0000000..a44fd61 --- /dev/null +++ b/src/features/subscriptions/domain/ports/subscription-repository.port.ts @@ -0,0 +1,10 @@ +import { ISubscription } from "../entities/ISubscription"; + +export interface ISubscriptionRepository { + findByUserId(userId: number): Promise; + findById(id: number): Promise; + create(subscription: Omit): Promise; + update(id: number, subscription: Partial): Promise; + cancel(id: number, retirementDate: Date): Promise; +} + diff --git a/src/features/subscriptions/infrastructure/adapters/plan.repository.ts b/src/features/subscriptions/infrastructure/adapters/plan.repository.ts new file mode 100644 index 0000000..8a39b15 --- /dev/null +++ b/src/features/subscriptions/infrastructure/adapters/plan.repository.ts @@ -0,0 +1,59 @@ +import { eq } from "drizzle-orm"; +import DatabaseConnection from "@/core/infrastructure/database"; +import { plans } from "@/schema"; +import { IPlanRepository } from "@/subscriptions/domain/ports/plan-repository.port"; +import { IPlan } from "@/subscriptions/domain/entities/IPlan"; + +export class PgPlanRepository implements IPlanRepository { + private db = DatabaseConnection.getInstance().db; + private static instance: PgPlanRepository; + + private constructor() {} + + public static getInstance(): PgPlanRepository { + if (!PgPlanRepository.instance) { + PgPlanRepository.instance = new PgPlanRepository(); + } + return PgPlanRepository.instance; + } + + async findAll(): Promise { + const result = await this.db.select().from(plans); + return result.map(this.mapToEntity); + } + + async findById(id: number): Promise { + const result = await this.db + .select() + .from(plans) + .where(eq(plans.id, id)) + .limit(1); + + return result[0] ? this.mapToEntity(result[0]) : null; + } + + async findByName(name: string): Promise { + const result = await this.db + .select() + .from(plans) + .where(eq(plans.name, name)) + .limit(1); + + return result[0] ? this.mapToEntity(result[0]) : null; + } + + private mapToEntity(raw: any): IPlan { + return { + id: raw.id, + name: raw.name, + durationDays: raw.duration_days, + price: Number(raw.price), + frequency: raw.frequency, + description: raw.description, + features: raw.features, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + }; + } +} + diff --git a/src/features/subscriptions/infrastructure/adapters/subscription-api.adapter.ts b/src/features/subscriptions/infrastructure/adapters/subscription-api.adapter.ts new file mode 100644 index 0000000..63ab6c4 --- /dev/null +++ b/src/features/subscriptions/infrastructure/adapters/subscription-api.adapter.ts @@ -0,0 +1,39 @@ +import { ISubscription } from "@/subscriptions/domain/entities/ISubscription"; +import { IPlan } from "@/subscriptions/domain/entities/IPlan"; + +export class SubscriptionApiAdapter { + static toApiResponse(subscription: ISubscription) { + return { + id: subscription.id, + userId: subscription.userId, + planId: subscription.planId, + plan: subscription.plan ? this.planToApiResponse(subscription.plan) : null, + frequency: subscription.frequency, + startDate: subscription.startDate, + endDate: subscription.endDate, + retirementDate: subscription.retirementDate || null, + active: subscription.active, + createdAt: subscription.createdAt, + updatedAt: subscription.updatedAt, + }; + } + + static planToApiResponse(plan: IPlan) { + return { + id: plan.id, + name: plan.name, + durationDays: plan.durationDays, + price: plan.price, + frequency: plan.frequency, + description: plan.description, + features: plan.features, + createdAt: plan.createdAt, + updatedAt: plan.updatedAt, + }; + } + + static planToApiResponseList(plans: IPlan[]) { + return plans.map(this.planToApiResponse); + } +} + diff --git a/src/features/subscriptions/infrastructure/adapters/subscription.repository.ts b/src/features/subscriptions/infrastructure/adapters/subscription.repository.ts new file mode 100644 index 0000000..01677ea --- /dev/null +++ b/src/features/subscriptions/infrastructure/adapters/subscription.repository.ts @@ -0,0 +1,209 @@ +import { eq, and } from "drizzle-orm"; +import DatabaseConnection from "@/core/infrastructure/database"; +import { subscriptions, plans } from "@/schema"; +import { ISubscriptionRepository } from "@/subscriptions/domain/ports/subscription-repository.port"; +import { ISubscription } from "@/subscriptions/domain/entities/ISubscription"; +import { IPlan } from "@/subscriptions/domain/entities/IPlan"; + +export class PgSubscriptionRepository implements ISubscriptionRepository { + private db = DatabaseConnection.getInstance().db; + private static instance: PgSubscriptionRepository; + + private constructor() {} + + public static getInstance(): PgSubscriptionRepository { + if (!PgSubscriptionRepository.instance) { + PgSubscriptionRepository.instance = new PgSubscriptionRepository(); + } + return PgSubscriptionRepository.instance; + } + + async findByUserId(userId: number): Promise { + const result = await this.db + .select({ + id: subscriptions.id, + user_id: subscriptions.user_id, + plan_id: subscriptions.plan_id, + plan: { + id: plans.id, + name: plans.name, + duration_days: plans.duration_days, + price: plans.price, + frequency: plans.frequency, + created_at: plans.created_at, + updated_at: plans.updated_at, + }, + frequency: subscriptions.frequency, + start_date: subscriptions.start_date, + end_date: subscriptions.end_date, + retirement_date: subscriptions.retirement_date, + active: subscriptions.active, + created_at: subscriptions.created_at, + updated_at: subscriptions.updated_at, + }) + .from(subscriptions) + .leftJoin(plans, eq(subscriptions.plan_id, plans.id)) + .where( + and( + eq(subscriptions.user_id, userId), + eq(subscriptions.active, true) + ) + ) + .limit(1); + + return result[0] ? this.mapToEntity(result[0]) : null; + } + + async findById(id: number): Promise { + const result = await this.db + .select({ + id: subscriptions.id, + user_id: subscriptions.user_id, + plan_id: subscriptions.plan_id, + plan: { + id: plans.id, + name: plans.name, + duration_days: plans.duration_days, + price: plans.price, + frequency: plans.frequency, + created_at: plans.created_at, + updated_at: plans.updated_at, + }, + frequency: subscriptions.frequency, + start_date: subscriptions.start_date, + end_date: subscriptions.end_date, + retirement_date: subscriptions.retirement_date, + active: subscriptions.active, + created_at: subscriptions.created_at, + updated_at: subscriptions.updated_at, + }) + .from(subscriptions) + .leftJoin(plans, eq(subscriptions.plan_id, plans.id)) + .where(eq(subscriptions.id, id)) + .limit(1); + + return result[0] ? this.mapToEntity(result[0]) : null; + } + + async create( + subscriptionData: Omit + ): Promise { + const existing = await this.findByUserId(subscriptionData.userId); + if (existing) { + await this.db + .update(subscriptions) + .set({ active: false }) + .where(eq(subscriptions.id, existing.id)); + } + + const result = await this.db + .insert(subscriptions) + .values({ + user_id: subscriptionData.userId, + plan_id: subscriptionData.planId, + frequency: subscriptionData.frequency, + start_date: subscriptionData.startDate, + end_date: subscriptionData.endDate, + retirement_date: subscriptionData.retirementDate || null, + active: subscriptionData.active, + }) + .returning(); + + const planResult = await this.db + .select() + .from(plans) + .where(eq(plans.id, subscriptionData.planId)) + .limit(1); + + return this.mapToEntity({ + ...result[0], + plan: planResult[0] || null, + }); + } + + async update( + id: number, + subscriptionData: Partial + ): Promise { + const updateData: Record = {}; + + if (subscriptionData.planId !== undefined) + updateData.plan_id = subscriptionData.planId; + if (subscriptionData.frequency !== undefined) + updateData.frequency = subscriptionData.frequency; + if (subscriptionData.startDate !== undefined) + updateData.start_date = subscriptionData.startDate; + if (subscriptionData.endDate !== undefined) + updateData.end_date = subscriptionData.endDate; + if (subscriptionData.retirementDate !== undefined) + updateData.retirement_date = subscriptionData.retirementDate; + if (subscriptionData.active !== undefined) + updateData.active = subscriptionData.active; + + const result = await this.db + .update(subscriptions) + .set(updateData) + .where(eq(subscriptions.id, id)) + .returning(); + + const planResult = await this.db + .select() + .from(plans) + .where(eq(plans.id, result[0].plan_id)) + .limit(1); + + return this.mapToEntity({ + ...result[0], + plan: planResult[0] || null, + }); + } + + async cancel(id: number, retirementDate: Date): Promise { + const result = await this.db + .update(subscriptions) + .set({ + retirement_date: retirementDate, + active: false, + }) + .where(eq(subscriptions.id, id)) + .returning(); + + const planResult = await this.db + .select() + .from(plans) + .where(eq(plans.id, result[0].plan_id)) + .limit(1); + + return this.mapToEntity({ + ...result[0], + plan: planResult[0] || null, + }); + } + + private mapToEntity(raw: any): ISubscription { + return { + id: raw.id, + userId: raw.user_id, + planId: raw.plan_id, + plan: raw.plan + ? { + id: raw.plan.id, + name: raw.plan.name, + durationDays: raw.plan.duration_days, + price: Number(raw.plan.price), + frequency: raw.plan.frequency, + createdAt: raw.plan.created_at, + updatedAt: raw.plan.updated_at, + } + : null, + frequency: raw.frequency, + startDate: new Date(raw.start_date), + endDate: new Date(raw.end_date), + retirementDate: raw.retirement_date ? new Date(raw.retirement_date) : null, + active: raw.active, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + }; + } +} + diff --git a/src/features/subscriptions/infrastructure/controllers/subscription.controller.ts b/src/features/subscriptions/infrastructure/controllers/subscription.controller.ts new file mode 100644 index 0000000..90bfd59 --- /dev/null +++ b/src/features/subscriptions/infrastructure/controllers/subscription.controller.ts @@ -0,0 +1,24 @@ +import { createRouter } from "@/core/infrastructure/lib/create-app"; +import * as routes from "@/subscriptions/infrastructure/controllers/subscription.routes"; +import { SubscriptionService } from "@/subscriptions/application/services/subscription.service"; +import { PgPlanRepository } from "@/subscriptions/infrastructure/adapters/plan.repository"; +import { PgSubscriptionRepository } from "@/subscriptions/infrastructure/adapters/subscription.repository"; +import { PgUserRepository } from "@/users/infrastructure/adapters/user.repository"; + +const planRepository = PgPlanRepository.getInstance(); +const subscriptionRepository = PgSubscriptionRepository.getInstance(); +const userRepository = PgUserRepository.getInstance(); +const subscriptionService = SubscriptionService.getInstance( + planRepository, + subscriptionRepository, + userRepository +); + +const router = createRouter() + .openapi(routes.listPlans, subscriptionService.listPlans) + .openapi(routes.setPlan, subscriptionService.setPlan) + .openapi(routes.cancelPlan, subscriptionService.cancelPlan) + .openapi(routes.getUserSubscription, subscriptionService.getUserSubscription); + +export default router; + diff --git a/src/features/subscriptions/infrastructure/controllers/subscription.routes.ts b/src/features/subscriptions/infrastructure/controllers/subscription.routes.ts new file mode 100644 index 0000000..f63661f --- /dev/null +++ b/src/features/subscriptions/infrastructure/controllers/subscription.routes.ts @@ -0,0 +1,161 @@ +import { createRoute } from "@hono/zod-openapi"; +import { z } from "zod"; +import * as HttpStatusCodes from "stoker/http-status-codes"; +import { jsonContentRequired } from "stoker/openapi/helpers"; +import { setPlanSchema } from "@/subscriptions/application/dtos/subscription.dto"; + +const tags = ["Subscriptions"]; + +const planSchema = z.object({ + id: z.number(), + name: z.string(), + durationDays: z.number(), + price: z.number(), + frequency: z.string(), + description: z.string().nullable(), + features: z.array(z.string()).nullable(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +const subscriptionSchema = z.object({ + id: z.number(), + userId: z.number(), + planId: z.number(), + plan: planSchema.nullable(), + frequency: z.string(), + startDate: z.date(), + endDate: z.date(), + retirementDate: z.date().nullable(), + active: z.boolean(), + createdAt: z.date(), + updatedAt: z.date(), +}); + +const baseResponseSchema = (schema: T) => + z.object({ + success: z.boolean(), + data: schema, + message: z.string(), + }); + +const errorResponseSchema = z.object({ + success: z.boolean(), + data: z.null(), + message: z.string(), +}); + +export const listPlans = createRoute({ + path: "/plans", + method: "get", + tags, + responses: { + [HttpStatusCodes.OK]: { + content: { + "application/json": { + schema: baseResponseSchema(z.array(planSchema)), + }, + }, + description: "Plans retrieved successfully", + }, + }, +}); + +export const setPlan = createRoute({ + path: "/subscriptions", + method: "post", + tags, + request: { + body: jsonContentRequired(setPlanSchema, "Set plan data"), + }, + responses: { + [HttpStatusCodes.CREATED]: { + content: { + "application/json": { + schema: baseResponseSchema(subscriptionSchema), + }, + }, + description: "Plan set successfully", + }, + [HttpStatusCodes.NOT_FOUND]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Plan or user not found", + }, + [HttpStatusCodes.BAD_REQUEST]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Invalid data", + }, + }, +}); + +export const cancelPlan = createRoute({ + path: "/subscriptions/:id/cancel", + method: "post", + tags, + request: { + params: z.object({ + id: z.string().regex(/^\d+$/).transform(Number), + }), + }, + responses: { + [HttpStatusCodes.OK]: { + content: { + "application/json": { + schema: baseResponseSchema(subscriptionSchema), + }, + }, + description: "Plan cancelled successfully", + }, + [HttpStatusCodes.NOT_FOUND]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Subscription not found", + }, + }, +}); + +export const getUserSubscription = createRoute({ + path: "/users/:userId/subscription", + method: "get", + tags, + request: { + params: z.object({ + userId: z.string().regex(/^\d+$/).transform(Number), + }), + }, + responses: { + [HttpStatusCodes.OK]: { + content: { + "application/json": { + schema: baseResponseSchema(subscriptionSchema.nullable()), + }, + }, + description: "User subscription retrieved successfully", + }, + [HttpStatusCodes.NOT_FOUND]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "User subscription not found", + }, + }, +}); + +export type ListPlansRoute = typeof listPlans; +export type SetPlanRoute = typeof setPlan; +export type CancelPlanRoute = typeof cancelPlan; +export type GetUserSubscriptionRoute = typeof getUserSubscription; + diff --git a/src/features/subscriptions/tests/subscription.service.test.ts b/src/features/subscriptions/tests/subscription.service.test.ts new file mode 100644 index 0000000..5542df3 --- /dev/null +++ b/src/features/subscriptions/tests/subscription.service.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SubscriptionService } from '../application/services/subscription.service'; +import { IPlanRepository } from '@/subscriptions/domain/ports/plan-repository.port'; +import { ISubscriptionRepository } from '@/subscriptions/domain/ports/subscription-repository.port'; +import { IUserRepository } from '@/users/domain/ports/user-repository.port'; +import * as HttpStatusCodes from 'stoker/http-status-codes'; + +describe('SubscriptionService', () => { + let subscriptionService: SubscriptionService; + let planRepositoryMock: IPlanRepository; + let subscriptionRepositoryMock: ISubscriptionRepository; + let userRepositoryMock: IUserRepository; + + beforeEach(() => { + planRepositoryMock = { + findAll: vi.fn(), + findById: vi.fn(), + findByName: vi.fn(), + } as any; + + subscriptionRepositoryMock = { + create: vi.fn(), + findById: vi.fn(), + cancel: vi.fn(), + findByUserId: vi.fn(), + } as any; + + userRepositoryMock = { + findById: vi.fn(), + } as any; + + (SubscriptionService as any).instance = null; + subscriptionService = SubscriptionService.getInstance( + planRepositoryMock, + subscriptionRepositoryMock, + userRepositoryMock + ); + }); + + describe('listPlans', () => { + it('should return all plans except demo', async () => { + const plans = [ + { id: 1, name: 'Basic', price: 10 }, + { id: 2, name: 'Plan Demo', price: 0 }, + ]; + (planRepositoryMock.findAll as any).mockResolvedValue(plans); + const c = { + json: vi.fn(), + }; + + await subscriptionService.listPlans(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.arrayContaining([expect.objectContaining({ name: 'Basic' })]), + }), + HttpStatusCodes.OK + ); + expect(c.json).not.toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.arrayContaining([expect.objectContaining({ name: 'Plan Demo' })]), + }), + HttpStatusCodes.OK + ); + }); + }); + + describe('setPlan', () => { + it('should return 404 if user not found', async () => { + (userRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { valid: vi.fn().mockReturnValue({ userId: 1, planId: 1 }) }, + json: vi.fn(), + }; + + await subscriptionService.setPlan(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return 404 if plan not found', async () => { + (userRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + (planRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { valid: vi.fn().mockReturnValue({ userId: 1, planId: 1 }) }, + json: vi.fn(), + }; + + await subscriptionService.setPlan(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Plan not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return 201 if plan set', async () => { + (userRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + (planRepositoryMock.findById as any).mockResolvedValue({ id: 1, durationDays: 30 }); + (subscriptionRepositoryMock.create as any).mockResolvedValue({ id: 1, userId: 1, planId: 1 }); + const c = { + req: { valid: vi.fn().mockReturnValue({ userId: 1, planId: 1, frequency: 'monthly' }) }, + json: vi.fn(), + }; + + await subscriptionService.setPlan(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ id: 1 }), + }), + HttpStatusCodes.CREATED + ); + }); + }); +}); diff --git a/src/features/transactions/application/dtos/transaction.dto.ts b/src/features/transactions/application/dtos/transaction.dto.ts index 48b1abc..728f8b3 100644 --- a/src/features/transactions/application/dtos/transaction.dto.ts +++ b/src/features/transactions/application/dtos/transaction.dto.ts @@ -58,8 +58,8 @@ export const updateTransactionSchema = transactionBaseSchema }); export const transactionFiltersSchema = z.object({ - startDate: z.string().datetime().optional(), - endDate: z.string().datetime().optional(), + startDate: z.string().date().optional(), + endDate: z.string().date().optional(), type: z.enum(["INCOME", "EXPENSE"]).optional(), category_id: z.coerce.number().int().positive().optional(), payment_method_id: z.coerce.number().int().positive().optional(), diff --git a/src/features/transactions/application/services/transactions.service.ts b/src/features/transactions/application/services/transactions.service.ts index d7401d4..0eb2ffe 100644 --- a/src/features/transactions/application/services/transactions.service.ts +++ b/src/features/transactions/application/services/transactions.service.ts @@ -4,402 +4,405 @@ import { createHandler } from "@/core/infrastructure/lib/handler.wrapper,"; import { TransactionApiAdapter } from "@/transactions/infrastructure/adapters/transaction-api.adapter"; import * as HttpStatusCodes from "stoker/http-status-codes"; import { - CreateRoute, - DeleteRoute, - FilterTransactionsRoute, - GetByIdRoute, - GetCategoryTotalsRoute, - GetMonthlyBalanceRoute, - GetMonthlyTrendsRoute, - ListByUserRoute, - ListRoute, - UpdateRoute, + CreateRoute, + DeleteRoute, + FilterTransactionsRoute, + GetByIdRoute, + GetCategoryTotalsRoute, + GetMonthlyBalanceRoute, + GetMonthlyTrendsRoute, + ListByUserRoute, + ListRoute, + UpdateRoute, } from "@/transactions/infrastructure/controllers/transaction.routes"; import { TransactionUtilsService } from "./transactions-utils.service"; export class TransactionService implements ITransactionService { - private static instance: TransactionService; - - constructor( - private readonly transactionRepository: ITransactionRepository, - private readonly transactionUtils: TransactionUtilsService - ) {} - - public static getInstance( - transactionRepository: ITransactionRepository, - transactionUtils: TransactionUtilsService - ): TransactionService { - if (!TransactionService.instance) { - TransactionService.instance = new TransactionService( - transactionRepository, - transactionUtils - ); - } - return TransactionService.instance; - } - - getAll = createHandler(async (c) => { - const transactions = await this.transactionRepository.findAll(); - return c.json( - { - success: true, - data: TransactionApiAdapter.toApiResponseList(transactions), - message: "Transactions retrieved successfully", - }, - HttpStatusCodes.OK - ); - }); - - getById = createHandler(async (c) => { - const id = c.req.param("id"); - const transaction = await this.transactionRepository.findById(Number(id)); - - if (!transaction) { - return c.json( - { - success: false, - data: null, - message: "Transaction not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - return c.json( - { - success: true, - data: TransactionApiAdapter.toApiResponse(transaction), - message: "Transaction retrieved successfully", - }, - HttpStatusCodes.OK - ); - }); - - getByUserId = createHandler(async (c) => { - const userId = c.req.param("userId"); - - const userValidation = await this.transactionUtils.validateUser( - Number(userId) - ); - if (!userValidation.isValid) { - return c.json( - { - success: false, - data: null, - message: "User not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - const transactions = await this.transactionRepository.findByUserId( - Number(userId) - ); - return c.json( - { - success: true, - data: TransactionApiAdapter.toApiResponseList(transactions), - message: "User transactions retrieved successfully", - }, - HttpStatusCodes.OK - ); - }); - - getFiltered = createHandler(async (c) => { - const userId = c.req.param("userId"); - const filters = c.req.valid("query"); - - const userValidation = await this.transactionUtils.validateUser( - Number(userId) - ); - if (!userValidation.isValid) { - return c.json( - { - success: false, - data: null, - message: "User not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - const transactions = await this.transactionRepository.findByFilters( - Number(userId), - filters - ); - - return c.json( - { - success: true, - data: TransactionApiAdapter.toApiResponseList(transactions), - message: "Filtered transactions retrieved successfully", - }, - HttpStatusCodes.OK - ); - }); - - create = createHandler(async (c) => { - const data = c.req.valid("json"); - - // Validar que el usuario existe - const userValidation = await this.transactionUtils.validateUser( - data.user_id - ); - if (!userValidation.isValid) { - return c.json( - { - success: false, - data: null, - message: "User not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - // Si se proporciona un método de pago, validar que existe y pertenece al usuario - if (data.payment_method_id) { - const paymentMethodValidation = - await this.transactionUtils.validatePaymentMethod( - data.payment_method_id, - data.user_id - ); - if (!paymentMethodValidation.isValid) { - return c.json( - { - success: false, - data: null, - message: - paymentMethodValidation.message || "Invalid payment method", - }, - HttpStatusCodes.BAD_REQUEST - ); - } - } - - const transaction = await this.transactionRepository.create({ - userId: data.user_id, - amount: data.amount, - type: data.type, - categoryId: data.category_id || null, - description: data.description, - paymentMethodId: data.payment_method_id, - scheduledTransactionId: data.scheduled_transaction_id, - debtId: data.debt_id, - }); - - return c.json( - { - success: true, - data: TransactionApiAdapter.toApiResponse(transaction), - message: "Transaction created successfully", - }, - HttpStatusCodes.CREATED - ); - }); - - update = createHandler(async (c) => { - const id = c.req.param("id"); - const data = c.req.valid("json"); - - console.log(data); - - const transaction = await this.transactionRepository.findById(Number(id)); - if (!transaction) { - return c.json( - { - success: false, - data: null, - message: "Transaction not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - if (data.payment_method_id !== undefined) { - if (data.payment_method_id !== null) { - const paymentMethodValidation = - await this.transactionUtils.validatePaymentMethod( - data.payment_method_id, - transaction.userId - ); - if (!paymentMethodValidation.isValid) { - return c.json( - { - success: false, - data: null, - message: - paymentMethodValidation.message || "Invalid payment method", - }, - HttpStatusCodes.BAD_REQUEST - ); - } - } - } - - const updatedTransaction = await this.transactionRepository.update( - Number(id), - { - amount: data.amount, - type: data.type, - categoryId: data.category_id, - description: data.description, - paymentMethodId: data.payment_method_id, - scheduledTransactionId: data.scheduled_transaction_id, - debtId: data.debt_id, - contributionId: data.contribution_id, - } - ); - - return c.json( - { - success: true, - data: TransactionApiAdapter.toApiResponse(updatedTransaction), - message: "Transaction updated successfully", - }, - HttpStatusCodes.OK - ); - }); - - delete = createHandler(async (c) => { - const id = c.req.param("id"); - const transaction = await this.transactionRepository.findById(Number(id)); - - if (!transaction) { - return c.json( - { - success: false, - data: null, - message: "Transaction not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - const deleted = await this.transactionRepository.delete(Number(id)); - return c.json( - { - success: true, - data: { deleted }, - message: "Transaction deleted successfully", - }, - HttpStatusCodes.OK - ); - }); - - getMonthlyBalance = createHandler(async (c) => { - const userId = c.req.param("userId"); - const { month } = c.req.valid("query"); - - const userValidation = await this.transactionUtils.validateUser( - Number(userId) - ); - if (!userValidation.isValid) { - return c.json( - { - success: false, - data: null, - message: "User not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - const balance = await this.transactionRepository.getMonthlyBalance( - Number(userId), - new Date(month) - ); - - return c.json( - { - success: true, - data: balance, - message: "Monthly balance retrieved successfully", - }, - HttpStatusCodes.OK - ); - }); - - getCategoryTotals = createHandler(async (c) => { - const userId = c.req.param("userId"); - const { startDate, endDate } = c.req.valid("query"); - - const userValidation = await this.transactionUtils.validateUser( - Number(userId) - ); - if (!userValidation.isValid) { - return c.json( - { - success: false, - data: null, - message: "User not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - const totals = await this.transactionRepository.getCategoryTotals( - Number(userId), - new Date(startDate), - new Date(endDate) - ); - - return c.json( - { - success: true, - data: totals.map((t) => ({ - category: t.categoryId.toString(), - total: t.total, - })), - message: "Category totals retrieved successfully", - }, - HttpStatusCodes.OK - ); - }); - - getMonthlyTrends = createHandler(async (c) => { - const userId = c.req.param("userId"); - - const userValidation = await this.transactionUtils.validateUser( - Number(userId) - ); - if (!userValidation.isValid) { - return c.json( - { - success: false, - data: null, - message: "User not found", - }, - HttpStatusCodes.NOT_FOUND - ); - } - - try { - const trends = await this.transactionRepository.getMonthlyTrends( - Number(userId) - ); - - return c.json( - { - success: true, - data: trends.map((t) => ({ - month: t.month, - income: t.income, - expense: t.expense, - })), - message: "Monthly trends retrieved successfully", - }, - HttpStatusCodes.OK - ); - } catch (error) { - console.error("Error getting monthly trends:", error); - return c.json( - { - success: false, - data: null, - message: "Error retrieving monthly trends", - }, - HttpStatusCodes.INTERNAL_SERVER_ERROR - ); - } - }); + private static instance: TransactionService; + + constructor( + private readonly transactionRepository: ITransactionRepository, + private readonly transactionUtils: TransactionUtilsService + ) {} + + public static getInstance( + transactionRepository: ITransactionRepository, + transactionUtils: TransactionUtilsService + ): TransactionService { + if (!TransactionService.instance) { + TransactionService.instance = new TransactionService( + transactionRepository, + transactionUtils + ); + } + return TransactionService.instance; + } + + getAll = createHandler(async (c) => { + const transactions = await this.transactionRepository.findAll(); + return c.json( + { + success: true, + data: TransactionApiAdapter.toApiResponseList(transactions), + message: "Transactions retrieved successfully", + }, + HttpStatusCodes.OK + ); + }); + + getById = createHandler(async (c) => { + const id = c.req.param("id"); + const transaction = await this.transactionRepository.findById(Number(id)); + + if (!transaction) { + return c.json( + { + success: false, + data: null, + message: "Transaction not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + return c.json( + { + success: true, + data: TransactionApiAdapter.toApiResponse(transaction), + message: "Transaction retrieved successfully", + }, + HttpStatusCodes.OK + ); + }); + + getByUserId = createHandler(async (c) => { + const userId = c.req.param("userId"); + + const userValidation = await this.transactionUtils.validateUser( + Number(userId) + ); + if (!userValidation.isValid) { + return c.json( + { + success: false, + data: null, + message: "User not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + const transactions = await this.transactionRepository.findByUserId( + Number(userId) + ); + return c.json( + { + success: true, + data: TransactionApiAdapter.toApiResponseList(transactions), + message: "User transactions retrieved successfully", + }, + HttpStatusCodes.OK + ); + }); + + getFiltered = createHandler(async (c) => { + const userId = c.req.param("userId"); + const filters = c.req.valid("query"); + + const userValidation = await this.transactionUtils.validateUser( + Number(userId) + ); + if (!userValidation.isValid) { + return c.json( + { + success: false, + data: null, + message: "User not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + const transactions = await this.transactionRepository.findByFilters( + Number(userId), + filters + ); + + return c.json( + { + success: true, + data: TransactionApiAdapter.toApiResponseList(transactions), + message: "Filtered transactions retrieved successfully", + }, + HttpStatusCodes.OK + ); + }); + + create = createHandler(async (c) => { + const data = c.req.valid("json"); + + // Validar que el usuario existe + const userValidation = await this.transactionUtils.validateUser( + data.user_id + ); + if (!userValidation.isValid) { + return c.json( + { + success: false, + data: null, + message: "User not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + // Si se proporciona un método de pago, validar que existe y pertenece al usuario + if (data.payment_method_id) { + const paymentMethodValidation = + await this.transactionUtils.validatePaymentMethod( + data.payment_method_id, + data.user_id + ); + if (!paymentMethodValidation.isValid) { + return c.json( + { + success: false, + data: null, + message: + paymentMethodValidation.message || "Invalid payment method", + }, + HttpStatusCodes.BAD_REQUEST + ); + } + } + + const transaction = await this.transactionRepository.create({ + userId: data.user_id, + amount: data.amount, + type: data.type, + categoryId: data.category_id || null, + category: null, + description: data.description, + paymentMethodId: data.payment_method_id, + paymentMethod: null, + scheduledTransactionId: data.scheduled_transaction_id, + debtId: data.debt_id, + }); + + return c.json( + { + success: true, + data: TransactionApiAdapter.toApiResponse(transaction), + message: "Transaction created successfully", + }, + HttpStatusCodes.CREATED + ); + }); + + update = createHandler(async (c) => { + const id = c.req.param("id"); + const data = c.req.valid("json"); + + console.log(data); + + const transaction = await this.transactionRepository.findById(Number(id)); + if (!transaction) { + return c.json( + { + success: false, + data: null, + message: "Transaction not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + if (data.payment_method_id !== undefined) { + if (data.payment_method_id !== null) { + const paymentMethodValidation = + await this.transactionUtils.validatePaymentMethod( + data.payment_method_id, + transaction.userId + ); + if (!paymentMethodValidation.isValid) { + return c.json( + { + success: false, + data: null, + message: + paymentMethodValidation.message || "Invalid payment method", + }, + HttpStatusCodes.BAD_REQUEST + ); + } + } + } + + const updatedTransaction = await this.transactionRepository.update( + Number(id), + { + amount: data.amount, + type: data.type, + categoryId: data.category_id, + description: data.description, + paymentMethodId: data.payment_method_id, + scheduledTransactionId: data.scheduled_transaction_id, + debtId: data.debt_id, + contributionId: data.contribution_id, + } + ); + + return c.json( + { + success: true, + data: TransactionApiAdapter.toApiResponse(updatedTransaction), + message: "Transaction updated successfully", + }, + HttpStatusCodes.OK + ); + }); + + delete = createHandler(async (c) => { + const id = c.req.param("id"); + const transaction = await this.transactionRepository.findById(Number(id)); + + if (!transaction) { + return c.json( + { + success: false, + data: null, + message: "Transaction not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + const deleted = await this.transactionRepository.delete(Number(id)); + return c.json( + { + success: true, + data: { deleted }, + message: "Transaction deleted successfully", + }, + HttpStatusCodes.OK + ); + }); + + getMonthlyBalance = createHandler(async (c) => { + const userId = c.req.param("userId"); + const { month } = c.req.valid("query"); + + const userValidation = await this.transactionUtils.validateUser( + Number(userId) + ); + if (!userValidation.isValid) { + return c.json( + { + success: false, + data: null, + message: "User not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + const balance = await this.transactionRepository.getMonthlyBalance( + Number(userId), + new Date(month) + ); + + return c.json( + { + success: true, + data: balance, + message: "Monthly balance retrieved successfully", + }, + HttpStatusCodes.OK + ); + }); + + getCategoryTotals = createHandler(async (c) => { + const userId = c.req.param("userId"); + const { startDate, endDate } = c.req.valid("query"); + + const userValidation = await this.transactionUtils.validateUser( + Number(userId) + ); + if (!userValidation.isValid) { + return c.json( + { + success: false, + data: null, + message: "User not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + const totals = await this.transactionRepository.getCategoryTotals( + Number(userId), + new Date(startDate), + new Date(endDate) + ); + + return c.json( + { + success: true, + data: totals.map((t) => ({ + category: t.categoryId.toString(), + categoryName: t.categoryName, + total: t.total, + })), + message: "Category totals retrieved successfully", + }, + HttpStatusCodes.OK + ); + }); + + getMonthlyTrends = createHandler(async (c) => { + const userId = c.req.param("userId"); + + const userValidation = await this.transactionUtils.validateUser( + Number(userId) + ); + if (!userValidation.isValid) { + return c.json( + { + success: false, + data: null, + message: "User not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + try { + const trends = await this.transactionRepository.getMonthlyTrends( + Number(userId) + ); + + return c.json( + { + success: true, + data: trends.map((t) => ({ + month: t.month, + income: t.income, + expense: t.expense, + })), + message: "Monthly trends retrieved successfully", + }, + HttpStatusCodes.OK + ); + } catch (error) { + console.error("Error getting monthly trends:", error); + return c.json( + { + success: false, + data: null, + message: "Error retrieving monthly trends", + }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ); + } + }); } diff --git a/src/features/transactions/domain/entities/ITrends.ts b/src/features/transactions/domain/entities/ITrends.ts index db6a5a2..2d470e0 100644 --- a/src/features/transactions/domain/entities/ITrends.ts +++ b/src/features/transactions/domain/entities/ITrends.ts @@ -9,3 +9,9 @@ export interface MonthlyTrendData { income: number; expense: number; } + +export interface CategoryTotalData { + categoryId: number; + categoryName: string | null; + total: number; +} diff --git a/src/features/transactions/domain/ports/transaction-repository.port.ts b/src/features/transactions/domain/ports/transaction-repository.port.ts index 43ac222..aee5d66 100644 --- a/src/features/transactions/domain/ports/transaction-repository.port.ts +++ b/src/features/transactions/domain/ports/transaction-repository.port.ts @@ -28,6 +28,7 @@ export interface ITransactionRepository { ): Promise< Array<{ categoryId: number; + categoryName: string | null; total: number; }> >; diff --git a/src/features/transactions/infrastructure/adapters/transaction.repository.ts b/src/features/transactions/infrastructure/adapters/transaction.repository.ts index 7e26640..9dd4aa7 100644 --- a/src/features/transactions/infrastructure/adapters/transaction.repository.ts +++ b/src/features/transactions/infrastructure/adapters/transaction.repository.ts @@ -5,586 +5,608 @@ import { ITransactionRepository } from "../../domain/ports/transaction-repositor import { ITransaction } from "../../domain/entities/ITransaction"; import { TransactionFilters } from "../../application/dtos/transaction.dto"; import { MonthlyTrendData } from "@/transactions/domain/entities/ITrends"; +import { setEndOfDay, setStartOfDay } from "@/shared/utils/date.utils"; export class PgTransactionRepository implements ITransactionRepository { - private db = DatabaseConnection.getInstance().db; - private static instance: PgTransactionRepository; - - private constructor() {} - - public static getInstance(): PgTransactionRepository { - if (!PgTransactionRepository.instance) { - PgTransactionRepository.instance = new PgTransactionRepository(); - } - return PgTransactionRepository.instance; - } - - async findAll(): Promise { - const result = await this.db - .select({ - id: transactions.id, - user_id: transactions.user_id, - amount: transactions.amount, - type: transactions.type, - category_id: transactions.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - description: transactions.description, - payment_method_id: transactions.payment_method_id, - payment_method: { - id: payment_methods.id, - name: payment_methods.name, - type: payment_methods.type, - last_four_digits: payment_methods.last_four_digits, - user_id: payment_methods.user_id, - }, - date: transactions.date, - scheduled_transaction_id: transactions.scheduled_transaction_id, - debt_id: transactions.debt_id, - budget_id: transactions.budget_id, - contribution_id: transactions.contribution_id, - }) - .from(transactions) - .leftJoin(categories, eq(transactions.category_id, categories.id)) - .leftJoin(payment_methods, eq(transactions.payment_method_id, payment_methods.id)); - return result.map(this.mapToEntity); - } - - async findById(id: number): Promise { - const result = await this.db - .select({ - id: transactions.id, - user_id: transactions.user_id, - amount: transactions.amount, - type: transactions.type, - category_id: transactions.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - description: transactions.description, - payment_method_id: transactions.payment_method_id, - payment_method: { - id: payment_methods.id, - name: payment_methods.name, - type: payment_methods.type, - last_four_digits: payment_methods.last_four_digits, - user_id: payment_methods.user_id, - }, - date: transactions.date, - scheduled_transaction_id: transactions.scheduled_transaction_id, - debt_id: transactions.debt_id, - budget_id: transactions.budget_id, - contribution_id: transactions.contribution_id, - }) - .from(transactions) - .leftJoin(categories, eq(transactions.category_id, categories.id)) - .leftJoin(payment_methods, eq(transactions.payment_method_id, payment_methods.id)) - .where(eq(transactions.id, id)); - return result[0] ? this.mapToEntity(result[0]) : null; - } - - async findByUserId(userId: number): Promise { - const result = await this.db - .select({ - id: transactions.id, - user_id: transactions.user_id, - amount: transactions.amount, - type: transactions.type, - category_id: transactions.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - description: transactions.description, - payment_method_id: transactions.payment_method_id, - payment_method: { - id: payment_methods.id, - name: payment_methods.name, - type: payment_methods.type, - last_four_digits: payment_methods.last_four_digits, - user_id: payment_methods.user_id, - }, - date: transactions.date, - scheduled_transaction_id: transactions.scheduled_transaction_id, - debt_id: transactions.debt_id, - budget_id: transactions.budget_id, - contribution_id: transactions.contribution_id, - }) - .from(transactions) - .leftJoin(categories, eq(transactions.category_id, categories.id)) - .leftJoin(payment_methods, eq(transactions.payment_method_id, payment_methods.id)) - .where(eq(transactions.user_id, userId)); - return result.map(this.mapToEntity); - } - - async findByUserIdAndDateRange(userId: number, startDate: Date, endDate: Date): Promise { - const result = await this.db - .select() - .from(transactions) - .where( - and( - eq(transactions.user_id, userId), - gte(transactions.date, startDate), - lte(transactions.date, endDate) - ) - ); - return result.map(this.mapToEntity); - } - - async findByFilters( - userId: number, - filters: TransactionFilters - ): Promise { - const conditions = [eq(transactions.user_id, userId)]; - - if (filters.startDate && filters.endDate) { - conditions.push( - between( - transactions.date, - new Date(filters.startDate), - new Date(filters.endDate) - ) - ); - } else if (filters.startDate) { - conditions.push(gte(transactions.date, new Date(filters.startDate))); - } else if (filters.endDate) { - conditions.push(lte(transactions.date, new Date(filters.endDate))); - } - - if (filters.type) { - conditions.push(eq(transactions.type, filters.type)); - } - - if (filters.category_id) { - conditions.push(eq(transactions.category_id, filters.category_id)); - } - - if (filters.payment_method_id) { - conditions.push( - eq(transactions.payment_method_id, filters.payment_method_id) - ); - } - - if (filters.min_amount) { - conditions.push( - gte(transactions.amount, sql`${filters.min_amount}::numeric`) - ); - } - - if (filters.max_amount) { - conditions.push( - lte(transactions.amount, sql`${filters.max_amount}::numeric`) - ); - } - - if (filters.debt_id) { - conditions.push(eq(transactions.debt_id, filters.debt_id)); - } - - if (filters.contribution_id) { - conditions.push(eq(transactions.contribution_id, filters.contribution_id)); - } - - if (filters.budget_id) { - conditions.push(eq(transactions.budget_id, filters.budget_id)); - } - - const result = await this.db - .select({ - id: transactions.id, - user_id: transactions.user_id, - amount: transactions.amount, - type: transactions.type, - category_id: transactions.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - description: transactions.description, - payment_method_id: transactions.payment_method_id, - payment_method: { - id: payment_methods.id, - name: payment_methods.name, - type: payment_methods.type, - last_four_digits: payment_methods.last_four_digits, - user_id: payment_methods.user_id, - }, - date: transactions.date, - scheduled_transaction_id: transactions.scheduled_transaction_id, - debt_id: transactions.debt_id, - budget_id: transactions.budget_id, - contribution_id: transactions.contribution_id, - }) - .from(transactions) - .leftJoin(categories, eq(transactions.category_id, categories.id)) - .leftJoin(payment_methods, eq(transactions.payment_method_id, payment_methods.id)) - .where(and(...conditions)) - .orderBy(transactions.date); - - return result.map(this.mapToEntity); - } - - async create( - transactionData: Omit - ): Promise { - const result = await this.db - .insert(transactions) - .values({ - user_id: transactionData.userId, - amount: transactionData.amount.toString(), - type: transactionData.type, - category_id: transactionData.categoryId, - description: transactionData.description || null, - payment_method_id: transactionData.paymentMethodId || null, - scheduled_transaction_id: - transactionData.scheduledTransactionId || null, - debt_id: transactionData.debtId || null, - contribution_id: transactionData.contributionId || null, - budget_id: transactionData.budgetId || null, - }) - .returning(); - - return this.mapToEntity(result[0]); - } - - async update( - id: number, - transactionData: Partial - ): Promise { - const updateData: Record = {}; - - if (transactionData.amount !== undefined) { - updateData.amount = transactionData.amount; - } - if (transactionData.type !== undefined) { - updateData.type = transactionData.type; - } - if (transactionData.categoryId !== undefined) { - updateData.category_id = transactionData.categoryId; - } - if (transactionData.description !== undefined) { - updateData.description = transactionData.description; - } - if (transactionData.paymentMethodId !== undefined) { - updateData.payment_method_id = transactionData.paymentMethodId; - } - if (transactionData.scheduledTransactionId !== undefined) { - updateData.scheduled_transaction_id = - transactionData.scheduledTransactionId; - } - if (transactionData.debtId !== undefined) { - updateData.debt_id = transactionData.debtId; - } - - const result = await this.db - .update(transactions) - .set(updateData) - .where(eq(transactions.id, id)) - .returning(); - - return this.mapToEntity(result[0]); - } - - async delete(id: number): Promise { - const result = await this.db - .delete(transactions) - .where(eq(transactions.id, id)) - .returning(); - - return result.length > 0; - } - - async getMonthlyBalance( - userId: number, - month: Date - ): Promise<{ - totalIncome: number; - totalExpense: number; - balance: number; - }> { - const year = month.getUTCFullYear(); - const monthNumber = month.getUTCMonth(); - - const startDate = new Date(Date.UTC(year, monthNumber, 1)); - const endDate = new Date(Date.UTC(year, monthNumber + 1, 0)); - - const result = await this.db - .select({ - type: transactions.type, - total: sql`sum(${transactions.amount})`, - }) - .from(transactions) - .where( - and( - eq(transactions.user_id, userId), - between(transactions.date, startDate, endDate) - ) - ) - .groupBy(transactions.type); - - const totals = { - totalIncome: 0, - totalExpense: 0, - balance: 0, - }; - - result.forEach((row) => { - if (row.type === "INCOME") { - totals.totalIncome = Number(row.total) || 0; - } else { - totals.totalExpense = Number(row.total) || 0; - } - }); - - totals.balance = totals.totalIncome - totals.totalExpense; - return totals; - } - - async getCategoryTotals( - userId: number, - startDate: Date, - endDate: Date - ): Promise> { - const utcStartDate = new Date( - Date.UTC( - startDate.getUTCFullYear(), - startDate.getUTCMonth(), - startDate.getUTCDate() - ) - ); - - const utcEndDate = new Date( - Date.UTC( - endDate.getUTCFullYear(), - endDate.getUTCMonth(), - endDate.getUTCDate(), - 23, - 59, - 59, - 999 - ) - ); - - console.log("UTC Start date:", utcStartDate.toISOString()); - console.log("UTC End date:", utcEndDate.toISOString()); - - const result = await this.db - .select({ - categoryId: transactions.category_id, - total: sql`sum(${transactions.amount})`, - }) - .from(transactions) - .where( - and( - eq(transactions.user_id, userId), - between(transactions.date, utcStartDate, utcEndDate) - ) - ) - .groupBy(transactions.category_id); - - return result.map((row) => ({ - categoryId: row.categoryId || 0, - total: Number(row.total) || 0, - })); - } - private mapToEntity(raw: any): ITransaction { - return { - id: raw.id, - userId: raw.user_id, - amount: Number(raw.amount), - type: raw.type, - categoryId: raw.category_id, - category: raw.category ? { - id: raw.category.id, - name: raw.category.name, - description: raw.category.description, - } : null, - description: raw.description, - paymentMethodId: raw.payment_method_id, - paymentMethod: raw.payment_method ? { - id: raw.payment_method.id, - name: raw.payment_method.name, - type: raw.payment_method.type, - lastFourDigits: raw.payment_method.last_four_digits, - userId: raw.payment_method.user_id, - createdAt: raw.payment_method.created_at, - updatedAt: raw.payment_method.updated_at, - } : null, - date: raw.date, - scheduledTransactionId: raw.scheduled_transaction_id, - debtId: raw.debt_id, - budgetId: raw.budget_id, - contributionId: raw.contribution_id, - createdAt: raw.created_at, - updatedAt: raw.updated_at, - }; - } - - async getMonthlyTrends(userId: number): Promise { - console.log("Getting monthly trends for user:", userId); - - try { - const result = await this.db - .select({ - month: sql`date_trunc('month', ${transactions.date})::date`, - type: transactions.type, - total: sql`COALESCE(sum(${transactions.amount}::numeric), 0)`, - }) - .from(transactions) - .where(eq(transactions.user_id, userId)) - .groupBy( - sql`date_trunc('month', ${transactions.date})::date`, - transactions.type - ) - .orderBy(sql`date_trunc('month', ${transactions.date})::date`); - - console.log("Raw query result:", result); - - if (!result || result.length === 0) { - return []; - } - - const monthlyData: Record = {}; - - result.forEach(({ month, type, total }) => { - const monthKey = month.substring(0, 7); - - if (!monthlyData[monthKey]) { - monthlyData[monthKey] = { - month: monthKey, - income: 0, - expense: 0, - }; - } - - if (type === "INCOME") { - monthlyData[monthKey].income = Number(total) || 0; - } else { - monthlyData[monthKey].expense = Number(total) || 0; - } - }); - - const trends = Object.values(monthlyData).sort( - (a, b) => new Date(a.month).getTime() - new Date(b.month).getTime() - ); - - console.log("Processed trends:", trends); - return trends; - } catch (error) { - console.error("Error in getMonthlyTrends:", error); - throw error; - } - } - - async findByDebtId(debtId: number): Promise { - const result = await this.db - .select({ - id: transactions.id, - user_id: transactions.user_id, - amount: transactions.amount, - type: transactions.type, - category_id: transactions.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - description: transactions.description, - payment_method_id: transactions.payment_method_id, - payment_method: { - id: payment_methods.id, - name: payment_methods.name, - type: payment_methods.type, - last_four_digits: payment_methods.last_four_digits, - user_id: payment_methods.user_id, - }, - date: transactions.date, - scheduled_transaction_id: transactions.scheduled_transaction_id, - debt_id: transactions.debt_id, - budget_id: transactions.budget_id, - contribution_id: transactions.contribution_id, - }) - .from(transactions) - .leftJoin(categories, eq(transactions.category_id, categories.id)) - .leftJoin(payment_methods, eq(transactions.payment_method_id, payment_methods.id)) - .where(eq(transactions.debt_id, debtId)); - return result.map(this.mapToEntity); - } - - async findByContributionId(contributionId: number): Promise { - const result = await this.db - .select({ - id: transactions.id, - user_id: transactions.user_id, - amount: transactions.amount, - type: transactions.type, - category_id: transactions.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - description: transactions.description, - payment_method_id: transactions.payment_method_id, - payment_method: { - id: payment_methods.id, - name: payment_methods.name, - type: payment_methods.type, - last_four_digits: payment_methods.last_four_digits, - user_id: payment_methods.user_id, - }, - date: transactions.date, - scheduled_transaction_id: transactions.scheduled_transaction_id, - debt_id: transactions.debt_id, - budget_id: transactions.budget_id, - contribution_id: transactions.contribution_id, - }) - .from(transactions) - .leftJoin(categories, eq(transactions.category_id, categories.id)) - .leftJoin(payment_methods, eq(transactions.payment_method_id, payment_methods.id)) - .where(eq(transactions.contribution_id, contributionId)); - return result.map(this.mapToEntity); - } - - async findByBudgetId(budgetId: number): Promise { - const result = await this.db - .select({ - id: transactions.id, - user_id: transactions.user_id, - amount: transactions.amount, - type: transactions.type, - category_id: transactions.category_id, - category: { - id: categories.id, - name: categories.name, - description: categories.description, - }, - description: transactions.description, - payment_method_id: transactions.payment_method_id, - payment_method: { - id: payment_methods.id, - name: payment_methods.name, - type: payment_methods.type, - last_four_digits: payment_methods.last_four_digits, - user_id: payment_methods.user_id, - }, - date: transactions.date, - scheduled_transaction_id: transactions.scheduled_transaction_id, - debt_id: transactions.debt_id, - budget_id: transactions.budget_id, - contribution_id: transactions.contribution_id, - }) - .from(transactions) - .leftJoin(categories, eq(transactions.category_id, categories.id)) - .leftJoin(payment_methods, eq(transactions.payment_method_id, payment_methods.id)) - .where(eq(transactions.budget_id, budgetId)); - return result.map(this.mapToEntity); - } + private db = DatabaseConnection.getInstance().db; + private static instance: PgTransactionRepository; + + private constructor() {} + + public static getInstance(): PgTransactionRepository { + if (!PgTransactionRepository.instance) { + PgTransactionRepository.instance = new PgTransactionRepository(); + } + return PgTransactionRepository.instance; + } + + async findAll(): Promise { + const result = await this.db + .select({ + id: transactions.id, + user_id: transactions.user_id, + amount: transactions.amount, + type: transactions.type, + category_id: transactions.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + description: transactions.description, + payment_method_id: transactions.payment_method_id, + payment_method: { + id: payment_methods.id, + name: payment_methods.name, + type: payment_methods.type, + last_four_digits: payment_methods.last_four_digits, + user_id: payment_methods.user_id, + }, + date: transactions.date, + scheduled_transaction_id: transactions.scheduled_transaction_id, + debt_id: transactions.debt_id, + budget_id: transactions.budget_id, + contribution_id: transactions.contribution_id, + }) + .from(transactions) + .leftJoin(categories, eq(transactions.category_id, categories.id)) + .leftJoin( + payment_methods, + eq(transactions.payment_method_id, payment_methods.id) + ); + return result.map(this.mapToEntity); + } + + async findById(id: number): Promise { + const result = await this.db + .select({ + id: transactions.id, + user_id: transactions.user_id, + amount: transactions.amount, + type: transactions.type, + category_id: transactions.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + description: transactions.description, + payment_method_id: transactions.payment_method_id, + payment_method: { + id: payment_methods.id, + name: payment_methods.name, + type: payment_methods.type, + last_four_digits: payment_methods.last_four_digits, + user_id: payment_methods.user_id, + }, + date: transactions.date, + scheduled_transaction_id: transactions.scheduled_transaction_id, + debt_id: transactions.debt_id, + budget_id: transactions.budget_id, + contribution_id: transactions.contribution_id, + }) + .from(transactions) + .leftJoin(categories, eq(transactions.category_id, categories.id)) + .leftJoin( + payment_methods, + eq(transactions.payment_method_id, payment_methods.id) + ) + .where(eq(transactions.id, id)); + return result[0] ? this.mapToEntity(result[0]) : null; + } + + async findByUserId(userId: number): Promise { + const result = await this.db + .select({ + id: transactions.id, + user_id: transactions.user_id, + amount: transactions.amount, + type: transactions.type, + category_id: transactions.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + description: transactions.description, + payment_method_id: transactions.payment_method_id, + payment_method: { + id: payment_methods.id, + name: payment_methods.name, + type: payment_methods.type, + last_four_digits: payment_methods.last_four_digits, + user_id: payment_methods.user_id, + }, + date: transactions.date, + scheduled_transaction_id: transactions.scheduled_transaction_id, + debt_id: transactions.debt_id, + budget_id: transactions.budget_id, + contribution_id: transactions.contribution_id, + }) + .from(transactions) + .leftJoin(categories, eq(transactions.category_id, categories.id)) + .leftJoin( + payment_methods, + eq(transactions.payment_method_id, payment_methods.id) + ) + .where(eq(transactions.user_id, userId)); + return result.map(this.mapToEntity); + } + + async findByUserIdAndDateRange( + userId: number, + startDate: Date, + endDate: Date + ): Promise { + const result = await this.db + .select() + .from(transactions) + .where( + and( + eq(transactions.user_id, userId), + gte(transactions.date, setStartOfDay(startDate)), + lte(transactions.date, setEndOfDay(endDate)) + ) + ); + return result.map(this.mapToEntity); + } + + async findByFilters( + userId: number, + filters: TransactionFilters + ): Promise { + const conditions = [eq(transactions.user_id, userId)]; + + if (filters.startDate && filters.endDate) { + conditions.push( + between( + transactions.date, + setStartOfDay(new Date(filters.startDate)), + setEndOfDay(new Date(filters.endDate)) + ) + ); + } else if (filters.startDate) { + conditions.push( + gte(transactions.date, setStartOfDay(new Date(filters.startDate))) + ); + } else if (filters.endDate) { + conditions.push( + lte(transactions.date, setEndOfDay(new Date(filters.endDate))) + ); + } + + if (filters.type) { + conditions.push(eq(transactions.type, filters.type)); + } + + if (filters.category_id) { + conditions.push(eq(transactions.category_id, filters.category_id)); + } + + if (filters.payment_method_id) { + conditions.push( + eq(transactions.payment_method_id, filters.payment_method_id) + ); + } + + if (filters.min_amount) { + conditions.push( + gte(transactions.amount, sql`${filters.min_amount}::numeric`) + ); + } + + if (filters.max_amount) { + conditions.push( + lte(transactions.amount, sql`${filters.max_amount}::numeric`) + ); + } + + if (filters.debt_id) { + conditions.push(eq(transactions.debt_id, filters.debt_id)); + } + + if (filters.contribution_id) { + conditions.push( + eq(transactions.contribution_id, filters.contribution_id) + ); + } + + if (filters.budget_id) { + conditions.push(eq(transactions.budget_id, filters.budget_id)); + } + + const result = await this.db + .select({ + id: transactions.id, + user_id: transactions.user_id, + amount: transactions.amount, + type: transactions.type, + category_id: transactions.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + description: transactions.description, + payment_method_id: transactions.payment_method_id, + payment_method: { + id: payment_methods.id, + name: payment_methods.name, + type: payment_methods.type, + last_four_digits: payment_methods.last_four_digits, + user_id: payment_methods.user_id, + }, + date: transactions.date, + scheduled_transaction_id: transactions.scheduled_transaction_id, + debt_id: transactions.debt_id, + budget_id: transactions.budget_id, + contribution_id: transactions.contribution_id, + }) + .from(transactions) + .leftJoin(categories, eq(transactions.category_id, categories.id)) + .leftJoin( + payment_methods, + eq(transactions.payment_method_id, payment_methods.id) + ) + .where(and(...conditions)) + .orderBy(transactions.date); + + return result.map(this.mapToEntity); + } + + async create( + transactionData: Omit + ): Promise { + const result = await this.db + .insert(transactions) + .values({ + user_id: transactionData.userId, + amount: transactionData.amount.toString(), + type: transactionData.type, + category_id: transactionData.categoryId, + description: transactionData.description || null, + payment_method_id: transactionData.paymentMethodId || null, + scheduled_transaction_id: + transactionData.scheduledTransactionId || null, + debt_id: transactionData.debtId || null, + contribution_id: transactionData.contributionId || null, + budget_id: transactionData.budgetId || null, + }) + .returning(); + + return this.mapToEntity(result[0]); + } + + async update( + id: number, + transactionData: Partial + ): Promise { + const updateData: Record = {}; + + if (transactionData.amount !== undefined) { + updateData.amount = transactionData.amount; + } + if (transactionData.type !== undefined) { + updateData.type = transactionData.type; + } + if (transactionData.categoryId !== undefined) { + updateData.category_id = transactionData.categoryId; + } + if (transactionData.description !== undefined) { + updateData.description = transactionData.description; + } + if (transactionData.paymentMethodId !== undefined) { + updateData.payment_method_id = transactionData.paymentMethodId; + } + if (transactionData.scheduledTransactionId !== undefined) { + updateData.scheduled_transaction_id = + transactionData.scheduledTransactionId; + } + if (transactionData.debtId !== undefined) { + updateData.debt_id = transactionData.debtId; + } + + const result = await this.db + .update(transactions) + .set(updateData) + .where(eq(transactions.id, id)) + .returning(); + + return this.mapToEntity(result[0]); + } + + async delete(id: number): Promise { + const result = await this.db + .delete(transactions) + .where(eq(transactions.id, id)) + .returning(); + + return result.length > 0; + } + + async getMonthlyBalance( + userId: number, + month: Date + ): Promise<{ + totalIncome: number; + totalExpense: number; + balance: number; + }> { + const year = month.getUTCFullYear(); + const monthNumber = month.getUTCMonth(); + + const startDate = new Date(Date.UTC(year, monthNumber, 1)); + const endDate = new Date(Date.UTC(year, monthNumber + 1, 0)); + + const result = await this.db + .select({ + type: transactions.type, + total: sql`sum(${transactions.amount})`, + }) + .from(transactions) + .where( + and( + eq(transactions.user_id, userId), + between(transactions.date, startDate, endDate) + ) + ) + .groupBy(transactions.type); + + const totals = { + totalIncome: 0, + totalExpense: 0, + balance: 0, + }; + + result.forEach((row) => { + if (row.type === "INCOME") { + totals.totalIncome = Number(row.total) || 0; + } else { + totals.totalExpense = Number(row.total) || 0; + } + }); + + totals.balance = totals.totalIncome - totals.totalExpense; + return totals; + } + + async getCategoryTotals( + userId: number, + startDate: Date, + endDate: Date + ): Promise< + Array<{ categoryId: number; categoryName: string | null; total: number }> + > { + const utcStartDate = setStartOfDay(startDate); + const utcEndDate = setEndOfDay(endDate); + + + const result = await this.db + .select({ + categoryId: transactions.category_id, + categoryName: categories.name, + total: sql`sum(${transactions.amount})`, + }) + .from(transactions) + .leftJoin(categories, eq(transactions.category_id, categories.id)) + .where( + and( + eq(transactions.user_id, userId), + between(transactions.date, utcStartDate, utcEndDate) + ) + ) + .groupBy(transactions.category_id, categories.name); + + return result.map((row) => ({ + categoryId: row.categoryId || 0, + categoryName: row.categoryName, + total: Number(row.total) || 0, + })); + } + private mapToEntity(raw: any): ITransaction { + return { + id: raw.id, + userId: raw.user_id, + amount: Number(raw.amount), + type: raw.type, + categoryId: raw.category_id, + category: raw.category + ? { + id: raw.category.id, + name: raw.category.name, + description: raw.category.description, + } + : null, + description: raw.description, + paymentMethodId: raw.payment_method_id, + paymentMethod: raw.payment_method + ? { + id: raw.payment_method.id, + name: raw.payment_method.name, + type: raw.payment_method.type, + lastFourDigits: raw.payment_method.last_four_digits, + userId: raw.payment_method.user_id, + createdAt: raw.payment_method.created_at, + updatedAt: raw.payment_method.updated_at, + } + : null, + date: raw.date, + scheduledTransactionId: raw.scheduled_transaction_id, + debtId: raw.debt_id, + budgetId: raw.budget_id, + contributionId: raw.contribution_id, + createdAt: raw.created_at, + updatedAt: raw.updated_at, + }; + } + + async getMonthlyTrends(userId: number): Promise { + console.log("Getting monthly trends for user:", userId); + + try { + const result = await this.db + .select({ + month: sql`date_trunc('month', ${transactions.date})::date`, + type: transactions.type, + total: sql`COALESCE(sum(${transactions.amount}::numeric), 0)`, + }) + .from(transactions) + .where(eq(transactions.user_id, userId)) + .groupBy( + sql`date_trunc('month', ${transactions.date})::date`, + transactions.type + ) + .orderBy(sql`date_trunc('month', ${transactions.date})::date`); + + console.log("Raw query result:", result); + + if (!result || result.length === 0) { + return []; + } + + const monthlyData: Record = {}; + + result.forEach(({ month, type, total }) => { + const monthKey = month.substring(0, 7); + + if (!monthlyData[monthKey]) { + monthlyData[monthKey] = { + month: monthKey, + income: 0, + expense: 0, + }; + } + + if (type === "INCOME") { + monthlyData[monthKey].income = Number(total) || 0; + } else { + monthlyData[monthKey].expense = Number(total) || 0; + } + }); + + const trends = Object.values(monthlyData).sort( + (a, b) => new Date(a.month).getTime() - new Date(b.month).getTime() + ); + + console.log("Processed trends:", trends); + return trends; + } catch (error) { + console.error("Error in getMonthlyTrends:", error); + throw error; + } + } + + async findByDebtId(debtId: number): Promise { + const result = await this.db + .select({ + id: transactions.id, + user_id: transactions.user_id, + amount: transactions.amount, + type: transactions.type, + category_id: transactions.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + description: transactions.description, + payment_method_id: transactions.payment_method_id, + payment_method: { + id: payment_methods.id, + name: payment_methods.name, + type: payment_methods.type, + last_four_digits: payment_methods.last_four_digits, + user_id: payment_methods.user_id, + }, + date: transactions.date, + scheduled_transaction_id: transactions.scheduled_transaction_id, + debt_id: transactions.debt_id, + budget_id: transactions.budget_id, + contribution_id: transactions.contribution_id, + }) + .from(transactions) + .leftJoin(categories, eq(transactions.category_id, categories.id)) + .leftJoin( + payment_methods, + eq(transactions.payment_method_id, payment_methods.id) + ) + .where(eq(transactions.debt_id, debtId)); + return result.map(this.mapToEntity); + } + + async findByContributionId(contributionId: number): Promise { + const result = await this.db + .select({ + id: transactions.id, + user_id: transactions.user_id, + amount: transactions.amount, + type: transactions.type, + category_id: transactions.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + description: transactions.description, + payment_method_id: transactions.payment_method_id, + payment_method: { + id: payment_methods.id, + name: payment_methods.name, + type: payment_methods.type, + last_four_digits: payment_methods.last_four_digits, + user_id: payment_methods.user_id, + }, + date: transactions.date, + scheduled_transaction_id: transactions.scheduled_transaction_id, + debt_id: transactions.debt_id, + budget_id: transactions.budget_id, + contribution_id: transactions.contribution_id, + }) + .from(transactions) + .leftJoin(categories, eq(transactions.category_id, categories.id)) + .leftJoin( + payment_methods, + eq(transactions.payment_method_id, payment_methods.id) + ) + .where(eq(transactions.contribution_id, contributionId)); + return result.map(this.mapToEntity); + } + + async findByBudgetId(budgetId: number): Promise { + const result = await this.db + .select({ + id: transactions.id, + user_id: transactions.user_id, + amount: transactions.amount, + type: transactions.type, + category_id: transactions.category_id, + category: { + id: categories.id, + name: categories.name, + description: categories.description, + }, + description: transactions.description, + payment_method_id: transactions.payment_method_id, + payment_method: { + id: payment_methods.id, + name: payment_methods.name, + type: payment_methods.type, + last_four_digits: payment_methods.last_four_digits, + user_id: payment_methods.user_id, + }, + date: transactions.date, + scheduled_transaction_id: transactions.scheduled_transaction_id, + debt_id: transactions.debt_id, + budget_id: transactions.budget_id, + contribution_id: transactions.contribution_id, + }) + .from(transactions) + .leftJoin(categories, eq(transactions.category_id, categories.id)) + .leftJoin( + payment_methods, + eq(transactions.payment_method_id, payment_methods.id) + ) + .where(eq(transactions.budget_id, budgetId)); + return result.map(this.mapToEntity); + } } diff --git a/src/features/transactions/tests/transactions.service.test.ts b/src/features/transactions/tests/transactions.service.test.ts new file mode 100644 index 0000000..5318bee --- /dev/null +++ b/src/features/transactions/tests/transactions.service.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TransactionService } from '../application/services/transactions.service'; +import { ITransactionRepository } from '@/transactions/domain/ports/transaction-repository.port'; +import { TransactionUtilsService } from '../application/services/transactions-utils.service'; +import * as HttpStatusCodes from 'stoker/http-status-codes'; +import { TransactionApiAdapter } from '@/transactions/infrastructure/adapters/transaction-api.adapter'; + +vi.mock('@/transactions/infrastructure/adapters/transaction-api.adapter', () => ({ + TransactionApiAdapter: { + toApiResponseList: vi.fn((data) => data), + toApiResponse: vi.fn((data) => data), + }, +})); + +describe('TransactionService', () => { + let transactionService: TransactionService; + let transactionRepositoryMock: ITransactionRepository; + let transactionUtilsMock: TransactionUtilsService; + + beforeEach(() => { + transactionRepositoryMock = { + findAll: vi.fn(), + findById: vi.fn(), + findByUserId: vi.fn(), + findByFilters: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getMonthlyBalance: vi.fn(), + getCategoryTotals: vi.fn(), + getMonthlyTrends: vi.fn(), + findByContributionId: vi.fn(), + } as any; + + transactionUtilsMock = { + validateUser: vi.fn(), + validatePaymentMethod: vi.fn(), + } as any; + + (TransactionService as any).instance = null; + transactionService = TransactionService.getInstance( + transactionRepositoryMock, + transactionUtilsMock + ); + }); + + describe('getAll', () => { + it('should return all transactions', async () => { + const transactions = [{ id: 1, amount: 100 }]; + (transactionRepositoryMock.findAll as any).mockResolvedValue(transactions); + const c = { + json: vi.fn(), + }; + + await transactionService.getAll(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.arrayContaining([expect.objectContaining({ amount: 100 })]), + }), + HttpStatusCodes.OK + ); + }); + }); + + describe('getById', () => { + it('should return 404 if transaction not found', async () => { + (transactionRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await transactionService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Transaction not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return transaction if found', async () => { + const transaction = { id: 1, amount: 100 }; + (transactionRepositoryMock.findById as any).mockResolvedValue(transaction); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await transactionService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ amount: 100 }), + }), + HttpStatusCodes.OK + ); + }); + }); + + describe('create', () => { + it('should return 404 if user not found', async () => { + (transactionUtilsMock.validateUser as any).mockResolvedValue({ isValid: false }); + const c = { + req: { valid: vi.fn().mockReturnValue({ user_id: 1 }) }, + json: vi.fn(), + }; + + await transactionService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return 400 if payment method invalid', async () => { + (transactionUtilsMock.validateUser as any).mockResolvedValue({ isValid: true }); + (transactionUtilsMock.validatePaymentMethod as any).mockResolvedValue({ isValid: false }); + const c = { + req: { valid: vi.fn().mockReturnValue({ user_id: 1, payment_method_id: 2 }) }, + json: vi.fn(), + }; + + await transactionService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Invalid payment method' }), + HttpStatusCodes.BAD_REQUEST + ); + }); + + it('should create transaction successfully', async () => { + (transactionUtilsMock.validateUser as any).mockResolvedValue({ isValid: true }); + const transactionData = { + user_id: 1, + amount: 100, + type: 'EXPENSE', + description: 'Test', + }; + const createdTransaction = { id: 1, ...transactionData }; + (transactionRepositoryMock.create as any).mockResolvedValue(createdTransaction); + const c = { + req: { valid: vi.fn().mockReturnValue(transactionData) }, + json: vi.fn(), + }; + + await transactionService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ amount: 100 }), + }), + HttpStatusCodes.CREATED + ); + }); + }); + + describe('update', () => { + it('should return 404 if transaction not found', async () => { + (transactionRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1'), valid: vi.fn().mockReturnValue({}) }, + json: vi.fn(), + }; + + await transactionService.update(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Transaction not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should update transaction successfully', async () => { + const existingTransaction = { id: 1, amount: 100, userId: 1 }; + (transactionRepositoryMock.findById as any).mockResolvedValue(existingTransaction); + const updateData = { amount: 200 }; + const updatedTransaction = { ...existingTransaction, ...updateData }; + (transactionRepositoryMock.update as any).mockResolvedValue(updatedTransaction); + + const c = { + req: { param: vi.fn().mockReturnValue('1'), valid: vi.fn().mockReturnValue(updateData) }, + json: vi.fn(), + }; + + await transactionService.update(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ amount: 200 }), + }), + HttpStatusCodes.OK + ); + }); + }); + + describe('delete', () => { + it('should return 404 if transaction not found', async () => { + (transactionRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await transactionService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'Transaction not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should delete transaction successfully', async () => { + (transactionRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + (transactionRepositoryMock.delete as any).mockResolvedValue(true); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await transactionService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: { deleted: true }, + }), + HttpStatusCodes.OK + ); + }); + }); +}); diff --git a/src/features/users/application/services/user.service.ts b/src/features/users/application/services/user.service.ts index c1fcf32..813046f 100644 --- a/src/features/users/application/services/user.service.ts +++ b/src/features/users/application/services/user.service.ts @@ -105,6 +105,25 @@ export class UserService implements IUserService { active: true, }); + const { PgPlanRepository } = await import("@/subscriptions/infrastructure/adapters/plan.repository"); + const { PgSubscriptionRepository } = await import("@/subscriptions/infrastructure/adapters/subscription.repository"); + const planRepository = PgPlanRepository.getInstance(); + const subscriptionRepository = PgSubscriptionRepository.getInstance(); + const demoPlan = await planRepository.findByName("demo"); + if (demoPlan) { + const startDate = new Date(); + const endDate = new Date(); + endDate.setDate(endDate.getDate() + demoPlan.durationDays); + await subscriptionRepository.create({ + userId: user.id, + planId: demoPlan.id, + frequency: demoPlan.frequency, + startDate, + endDate, + active: true, + }); + } + return c.json( { success: true, diff --git a/src/features/users/domain/entities/IUser.ts b/src/features/users/domain/entities/IUser.ts index c5ac2aa..dadd97a 100644 --- a/src/features/users/domain/entities/IUser.ts +++ b/src/features/users/domain/entities/IUser.ts @@ -4,8 +4,8 @@ export interface IUser { username: string; email: string; passwordHash: string; - registrationDate: Date; + registration_date: Date; active: boolean; recoveryToken?: string | null; recoveryTokenExpires?: Date | null; -} \ No newline at end of file +} diff --git a/src/features/users/domain/ports/user-repository.port.ts b/src/features/users/domain/ports/user-repository.port.ts index 5d4d3b6..af7f5ab 100644 --- a/src/features/users/domain/ports/user-repository.port.ts +++ b/src/features/users/domain/ports/user-repository.port.ts @@ -1,14 +1,14 @@ import { IUser } from "../entities/IUser"; export interface IUserRepository { - findAll(): Promise; - findById(id: number): Promise; - findByEmail(email: string): Promise; - findByUsername(IUsername: string): Promise; - findByRecoveryToken(token: string): Promise; - create(IUser: Omit): Promise; - update(id: number, IUser: Partial): Promise; - delete(id: number): Promise; - setRecoveryToken(id: number, token: string, expires: Date): Promise; - clearRecoveryToken(id: number): Promise; + findAll(): Promise; + findById(id: number): Promise; + findByEmail(email: string): Promise; + findByUsername(IUsername: string): Promise; + findByRecoveryToken(token: string): Promise; + create(IUser: Omit): Promise; + update(id: number, IUser: Partial): Promise; + delete(id: number): Promise; + setRecoveryToken(id: number, token: string, expires: Date): Promise; + clearRecoveryToken(id: number): Promise; } diff --git a/src/features/users/infrastructure/adapters/user-api.adapter.ts b/src/features/users/infrastructure/adapters/user-api.adapter.ts index 106df04..080b441 100644 --- a/src/features/users/infrastructure/adapters/user-api.adapter.ts +++ b/src/features/users/infrastructure/adapters/user-api.adapter.ts @@ -3,23 +3,23 @@ import { IUser } from "@/users/domain/entities/IUser"; import { z } from "zod"; export class UserApiAdapter { - static toApiResponse(user: IUser): z.infer { - return { - id: user.id, - name: user.name, - email: user.email, - active: user.active, - username: user.username, - password_hash: user.passwordHash, - registration_date: user.registrationDate, - recovery_token: user.recoveryToken || null, - recovery_token_expires: user.recoveryTokenExpires || null, - }; - } + static toApiResponse(user: IUser): z.infer { + return { + id: user.id, + name: user.name, + email: user.email, + active: user.active, + username: user.username, + password_hash: user.passwordHash, + registration_date: user.registration_date, + recovery_token: user.recoveryToken || null, + recovery_token_expires: user.recoveryTokenExpires || null, + }; + } - static toApiResponseList( - users: IUser[] - ): z.infer[] { - return users.map(this.toApiResponse); - } + static toApiResponseList( + users: IUser[] + ): z.infer[] { + return users.map(this.toApiResponse); + } } diff --git a/src/features/users/infrastructure/adapters/user.repository.ts b/src/features/users/infrastructure/adapters/user.repository.ts index 8ea318d..1cc65fd 100644 --- a/src/features/users/infrastructure/adapters/user.repository.ts +++ b/src/features/users/infrastructure/adapters/user.repository.ts @@ -5,145 +5,145 @@ import { IUserRepository } from "@/users/domain/ports/user-repository.port"; import { IUser } from "@/users/domain/entities/IUser"; export class PgUserRepository implements IUserRepository { - private db = DatabaseConnection.getInstance().db; - private static instance: PgUserRepository; - - private constructor() {} - - public static getInstance(): PgUserRepository { - if (!PgUserRepository.instance) { - PgUserRepository.instance = new PgUserRepository(); - } - return PgUserRepository.instance; - } - - async findAll(): Promise { - const result = await this.db.select().from(users); - return result.map((raw) => this.mapToEntity(raw)); - } - - async findAllActive(): Promise { - const result = await this.db - .select() - .from(users) - .where(eq(users.active, true)); - return result.map((raw) => this.mapToEntity(raw)); - } - - async findById(id: number): Promise { - const result = await this.db.select().from(users).where(eq(users.id, id)); - return result[0] ? this.mapToEntity(result[0]) : null; - } - - async findByEmail(email: string): Promise { - const result = await this.db - .select() - .from(users) - .where(eq(users.email, email)); - return result[0] ? this.mapToEntity(result[0]) : null; - } - - async findByUsername(username: string): Promise { - const result = await this.db - .select() - .from(users) - .where(eq(users.username, username)); - return result[0] ? this.mapToEntity(result[0]) : null; - } - - async create( - userData: Omit - ): Promise { - const result = await this.db - .insert(users) - .values({ - name: userData.name, - username: userData.username, - email: userData.email, - password_hash: userData.passwordHash, - active: userData.active, - }) - .returning(); - - return this.mapToEntity(result[0]); - } - - async findByRecoveryToken(token: string): Promise { - const result = await this.db - .select() - .from(users) - .where(eq(users.recovery_token, token)); - - return result[0] ? this.mapToEntity(result[0]) : null; - } - - async update(id: number, userData: Partial): Promise { - const result = await this.db - .update(users) - .set({ - name: userData.name, - username: userData.username, - email: userData.email, - password_hash: userData.passwordHash, - active: userData.active, - }) - .where(eq(users.id, id)) - .returning(); - - return this.mapToEntity(result[0]); - } - - async delete(id: number): Promise { - const result = await this.db - .update(users) - .set({ active: false }) - .where(eq(users.id, id)) - .returning(); - - return result.length > 0; - } - - async setRecoveryToken( - id: number, - token: string, - expires: Date - ): Promise { - const result = await this.db - .update(users) - .set({ - recovery_token: token, - recovery_token_expires: expires, - }) - .where(eq(users.id, id)) - .returning(); - - return result.length > 0; - } - - async clearRecoveryToken(id: number): Promise { - const result = await this.db - .update(users) - .set({ - recovery_token: null, - recovery_token_expires: null, - }) - .where(eq(users.id, id)) - .returning(); - - return result.length > 0; - } - - private mapToEntity(raw: any): IUser { - return { - id: raw.id, - name: raw.name, - username: raw.username, - email: raw.email, - passwordHash: raw.password_hash, - registrationDate: raw.registration_date, - active: raw.active, - recoveryToken: raw.recovery_token, - recoveryTokenExpires: raw.recovery_token_expires, - }; - } + private db = DatabaseConnection.getInstance().db; + private static instance: PgUserRepository; + + private constructor() {} + + public static getInstance(): PgUserRepository { + if (!PgUserRepository.instance) { + PgUserRepository.instance = new PgUserRepository(); + } + return PgUserRepository.instance; + } + + async findAll(): Promise { + const result = await this.db.select().from(users); + return result.map((raw) => this.mapToEntity(raw)); + } + + async findAllActive(): Promise { + const result = await this.db + .select() + .from(users) + .where(eq(users.active, true)); + return result.map((raw) => this.mapToEntity(raw)); + } + + async findById(id: number): Promise { + const result = await this.db.select().from(users).where(eq(users.id, id)); + return result[0] ? this.mapToEntity(result[0]) : null; + } + + async findByEmail(email: string): Promise { + const result = await this.db + .select() + .from(users) + .where(eq(users.email, email)); + return result[0] ? this.mapToEntity(result[0]) : null; + } + + async findByUsername(username: string): Promise { + const result = await this.db + .select() + .from(users) + .where(eq(users.username, username)); + return result[0] ? this.mapToEntity(result[0]) : null; + } + + async create( + userData: Omit + ): Promise { + const result = await this.db + .insert(users) + .values({ + name: userData.name, + username: userData.username, + email: userData.email, + password_hash: userData.passwordHash, + active: userData.active, + }) + .returning(); + + return this.mapToEntity(result[0]); + } + + async findByRecoveryToken(token: string): Promise { + const result = await this.db + .select() + .from(users) + .where(eq(users.recovery_token, token)); + + return result[0] ? this.mapToEntity(result[0]) : null; + } + + async update(id: number, userData: Partial): Promise { + const result = await this.db + .update(users) + .set({ + name: userData.name, + username: userData.username, + email: userData.email, + password_hash: userData.passwordHash, + active: userData.active, + }) + .where(eq(users.id, id)) + .returning(); + + return this.mapToEntity(result[0]); + } + + async delete(id: number): Promise { + const result = await this.db + .update(users) + .set({ active: false }) + .where(eq(users.id, id)) + .returning(); + + return result.length > 0; + } + + async setRecoveryToken( + id: number, + token: string, + expires: Date + ): Promise { + const result = await this.db + .update(users) + .set({ + recovery_token: token, + recovery_token_expires: expires, + }) + .where(eq(users.id, id)) + .returning(); + + return result.length > 0; + } + + async clearRecoveryToken(id: number): Promise { + const result = await this.db + .update(users) + .set({ + recovery_token: null, + recovery_token_expires: null, + }) + .where(eq(users.id, id)) + .returning(); + + return result.length > 0; + } + + private mapToEntity(raw: any): IUser { + return { + id: raw.id, + name: raw.name, + username: raw.username, + email: raw.email, + passwordHash: raw.password_hash, + registration_date: raw.registration_date, + active: raw.active, + recoveryToken: raw.recovery_token, + recoveryTokenExpires: raw.recovery_token_expires, + }; + } } diff --git a/src/features/users/tests/user.service.test.ts b/src/features/users/tests/user.service.test.ts new file mode 100644 index 0000000..ba7c931 --- /dev/null +++ b/src/features/users/tests/user.service.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { UserService } from '../application/services/user.service'; +import { IUserRepository } from '@/users/domain/ports/user-repository.port'; +import { UserUtilsService } from '../application/services/user-utils.service'; +import * as HttpStatusCodes from 'stoker/http-status-codes'; +import { hash } from '@/shared/utils/crypto.util'; +import { PgPlanRepository } from '@/subscriptions/infrastructure/adapters/plan.repository'; +import { PgSubscriptionRepository } from '@/subscriptions/infrastructure/adapters/subscription.repository'; +import { UserApiAdapter } from '@/users/infrastructure/adapters/user-api.adapter'; + +// Mocks +vi.mock('@/users/infrastructure/adapters/user-api.adapter', () => ({ + UserApiAdapter: { + toApiResponseList: vi.fn((data) => data), + toApiResponse: vi.fn((data) => data), + }, +})); +vi.mock('@/shared/utils/crypto.util', () => ({ + hash: vi.fn(), +})); +vi.mock('@/subscriptions/infrastructure/adapters/plan.repository', () => ({ + PgPlanRepository: { + getInstance: vi.fn(), + }, +})); +vi.mock('@/subscriptions/infrastructure/adapters/subscription.repository', () => ({ + PgSubscriptionRepository: { + getInstance: vi.fn(), + }, +})); + +describe('UserService', () => { + let userService: UserService; + let userRepositoryMock: IUserRepository; + let userUtilsMock: UserUtilsService; + let planRepositoryMock: any; + let subscriptionRepositoryMock: any; + + beforeEach(() => { + userRepositoryMock = { + findByEmail: vi.fn(), + findAll: vi.fn(), + create: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + setRecoveryToken: vi.fn(), + findByRecoveryToken: vi.fn(), + } as any; + + userUtilsMock = { + validateUniqueFields: vi.fn(), + validateEmailUnique: vi.fn(), + validateUsernameUnique: vi.fn(), + } as any; + + planRepositoryMock = { + findByName: vi.fn(), + }; + (PgPlanRepository.getInstance as any) = vi.fn().mockReturnValue(planRepositoryMock); + + subscriptionRepositoryMock = { + create: vi.fn(), + }; + (PgSubscriptionRepository.getInstance as any) = vi.fn().mockReturnValue(subscriptionRepositoryMock); + + (UserService as any).instance = null; + userService = UserService.getInstance(userRepositoryMock, userUtilsMock); + }); + + describe('searchByEmail', () => { + it('should return 404 if user not found', async () => { + (userRepositoryMock.findByEmail as any).mockResolvedValue(null); + const c = { + req: { valid: vi.fn().mockReturnValue({ email: 'test@example.com' }) }, + json: vi.fn(), + }; + + await userService.searchByEmail(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return 200 if user found', async () => { + (userRepositoryMock.findByEmail as any).mockResolvedValue({ + id: '1', + email: 'test@example.com', + name: 'Test', + username: 'test', + }); + const c = { + req: { valid: vi.fn().mockReturnValue({ email: 'test@example.com' }) }, + json: vi.fn(), + }; + + await userService.searchByEmail(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ email: 'test@example.com' }), + }), + HttpStatusCodes.OK + ); + }); + }); + + describe('create', () => { + it('should return 409 if fields not unique', async () => { + (userUtilsMock.validateUniqueFields as any).mockResolvedValue({ isValid: false, field: 'email' }); + const c = { + req: { valid: vi.fn().mockReturnValue({ email: 'test@example.com', username: 'test' }) }, + json: vi.fn(), + }; + + await userService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'The email is already taken' }), + HttpStatusCodes.CONFLICT + ); + }); + + it('should return 201 if user created', async () => { + (userUtilsMock.validateUniqueFields as any).mockResolvedValue({ isValid: true }); + (hash as any).mockResolvedValue('hashed'); + (userRepositoryMock.create as any).mockResolvedValue({ + id: '1', + email: 'test@example.com', + name: 'Test', + username: 'test', + active: true, + }); + (planRepositoryMock.findByName as any).mockResolvedValue({ id: 'plan1', durationDays: 30, frequency: 'monthly' }); + + const c = { + req: { valid: vi.fn().mockReturnValue({ + email: 'test@example.com', + username: 'test', + password: 'password', + name: 'Test', + }) }, + json: vi.fn(), + }; + + await userService.create(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ email: 'test@example.com' }), + }), + HttpStatusCodes.CREATED + ); + expect(subscriptionRepositoryMock.create).toHaveBeenCalled(); + }); + }); + describe('update', () => { + it('should return 404 if user not found', async () => { + (userRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1'), valid: vi.fn().mockReturnValue({}) }, + json: vi.fn(), + }; + + await userService.update(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should update user successfully', async () => { + const existingUser = { id: 1, email: 'test@example.com', username: 'test' }; + (userRepositoryMock.findById as any).mockResolvedValue(existingUser); + const updateData = { name: 'New Name' }; + const updatedUser = { ...existingUser, ...updateData }; + (userRepositoryMock.update as any).mockResolvedValue(updatedUser); + + const c = { + req: { param: vi.fn().mockReturnValue('1'), valid: vi.fn().mockReturnValue(updateData) }, + json: vi.fn(), + }; + + await userService.update(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ name: 'New Name' }), + }), + HttpStatusCodes.OK + ); + }); + }); + + describe('delete', () => { + it('should return 404 if user not found', async () => { + (userRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await userService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should delete user successfully', async () => { + (userRepositoryMock.findById as any).mockResolvedValue({ id: 1 }); + (userRepositoryMock.delete as any).mockResolvedValue(true); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await userService.delete(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: { deleted: true }, + }), + HttpStatusCodes.OK + ); + }); + }); + + describe('getById', () => { + it('should return 404 if user not found', async () => { + (userRepositoryMock.findById as any).mockResolvedValue(null); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await userService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ success: false, message: 'User not found' }), + HttpStatusCodes.NOT_FOUND + ); + }); + + it('should return user if found', async () => { + const user = { id: 1, email: 'test@example.com' }; + (userRepositoryMock.findById as any).mockResolvedValue(user); + const c = { + req: { param: vi.fn().mockReturnValue('1') }, + json: vi.fn(), + }; + + await userService.getById(c as any); + + expect(c.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ email: 'test@example.com' }), + }), + HttpStatusCodes.OK + ); + }); + }); +}); diff --git a/src/shared/utils/crypto.util.ts b/src/shared/utils/crypto.util.ts index 3628cbe..e4a9cde 100644 --- a/src/shared/utils/crypto.util.ts +++ b/src/shared/utils/crypto.util.ts @@ -1,10 +1,53 @@ +import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; + +const bunPasswordApi = + typeof globalThis !== "undefined" && + typeof (globalThis as any).Bun?.password?.hash === "function" && + typeof (globalThis as any).Bun?.password?.verify === "function" + ? (globalThis as any).Bun.password + : null; + +const hashWithNodeCrypto = (password: string): string => { + const salt = randomBytes(16).toString("hex"); + const derivedKey = scryptSync(password, salt, 64).toString("hex"); + return `${salt}:${derivedKey}`; +}; + +const verifyWithNodeCrypto = (password: string, hashedValue: string): boolean => { + const [salt, storedKey] = hashedValue.split(":"); + if (!salt || !storedKey) { + return false; + } + + const derivedKey = scryptSync(password, salt, 64); + const storedKeyBuffer = Buffer.from(storedKey, "hex"); + + if (derivedKey.length !== storedKeyBuffer.length) { + return false; + } + + return timingSafeEqual(derivedKey, storedKeyBuffer); +}; + export const hash = async (password: string): Promise => { - return Bun.password.hash(password); + if (bunPasswordApi) { + return bunPasswordApi.hash(password); + } + + return hashWithNodeCrypto(password); }; export const verify = async ( - password: string, - hash: string + password: string, + hashedValue: string ): Promise => { - return Bun.password.verify(password, hash); + if (bunPasswordApi) { + return bunPasswordApi.verify(password, hashedValue); + } + + try { + return verifyWithNodeCrypto(password, hashedValue); + } catch { + return false; + } }; diff --git a/src/shared/utils/date.utils.ts b/src/shared/utils/date.utils.ts new file mode 100644 index 0000000..ff768a0 --- /dev/null +++ b/src/shared/utils/date.utils.ts @@ -0,0 +1,22 @@ +/** + * Sets the end date with the last millisecond of the day (23:59:59.999) + * to ensure all transactions from the day are included + * @param date - Base date + * @returns New date with the last millisecond of the day + */ +export function setEndOfDay(date: Date): Date { + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); + return endOfDay; +} + +/** + * Sets the start date with the first hour of the day (00:00:00.000) + * @param date - Base date + * @returns New date with the first hour of the day + */ +export function setStartOfDay(date: Date): Date { + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + return startOfDay; +} diff --git a/tests/helpers/mocks.ts b/tests/helpers/mocks.ts new file mode 100644 index 0000000..ba3b1ec --- /dev/null +++ b/tests/helpers/mocks.ts @@ -0,0 +1,35 @@ +/** + * Mocks para servicios externos + */ + +export const mockEmailService = { + sendEmail: async (to: string, subject: string, body: string) => { + // Mock implementation - no envía emails reales + return { success: true }; + }, + sendPasswordResetEmail: async (email: string, token: string) => { + return { success: true }; + }, +}; + +export const mockNotificationService = { + create: async (notification: any) => { + return { id: 1, ...notification }; + }, + findByUserId: async (userId: number) => { + return []; + }, +}; + +/** + * Mock para servicios de IA/LLM + */ +export const mockLLMService = { + generateRecommendation: async (prompt: string) => { + return "Mock recommendation"; + }, + transcribeAudio: async (audio: Buffer) => { + return "Mock transcription"; + }, +}; + diff --git a/tests/helpers/test-db.ts b/tests/helpers/test-db.ts new file mode 100644 index 0000000..f890313 --- /dev/null +++ b/tests/helpers/test-db.ts @@ -0,0 +1,149 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import { getTableName } from "drizzle-orm"; +import { Pool } from "pg"; +import * as schema from "@/core/infrastructure/database/schema"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; + +/** + * Configuración de base de datos para tests + * Usa DATABASE_URL de test o crea una nueva conexión + */ +export class TestDatabase { + private static instance: TestDatabase; + private pool: Pool; + private db: NodePgDatabase; + + private constructor() { + // FORZAR uso de base de datos de test - NUNCA usar la base de datos principal + // Si no hay TEST_DATABASE_URL configurado, usar un nombre de BD diferente + let databaseUrl = process.env.TEST_DATABASE_URL; + + if (!databaseUrl) { + // Si hay DATABASE_URL, cambiar el nombre de la base de datos a uno de test + if (process.env.DATABASE_URL) { + const mainUrl = new URL(process.env.DATABASE_URL); + const mainDbName = mainUrl.pathname.replace("/", ""); + + // ADVERTENCIA: Verificar que no estamos usando la base de datos principal + if (!mainDbName.endsWith("_test") && !mainDbName.includes("test")) { + console.warn( + "⚠️ ADVERTENCIA: Se está usando la base de datos principal como test!" + ); + console.warn( + "⚠️ Configura TEST_DATABASE_URL en .env.test para usar una BD separada" + ); + console.warn("⚠️ Base de datos principal:", mainDbName); + + // Forzar cambio a nombre de test + mainUrl.pathname = `/${mainDbName}_test`; + databaseUrl = mainUrl.toString(); + console.warn("⚠️ Usando base de datos de test:", mainUrl.pathname); + } else { + databaseUrl = process.env.DATABASE_URL; + } + } else { + // Fallback a configuración por defecto de test + databaseUrl = + "postgresql://postgres:postgres@localhost:5432/fopymes_test"; + } + } + + // Verificación final de seguridad + const testDbName = new URL(databaseUrl).pathname + .replace("/", "") + .toLowerCase(); + if (!testDbName.includes("test") && process.env.NODE_ENV === "test") { + console.error( + "❌ ERROR CRÍTICO: La base de datos de test no contiene 'test' en el nombre!" + ); + console.error("❌ Base de datos:", testDbName); + console.error("❌ Esto podría borrar datos de producción!"); + throw new Error( + "Base de datos de test no configurada correctamente. " + + "Configura TEST_DATABASE_URL con una base de datos que contenga 'test' en el nombre." + ); + } + + console.log( + "🔧 TestDatabase usando:", + databaseUrl.replace(/:[^:@]+@/, ":****@") + ); // Ocultar password en log + + this.pool = new Pool({ + connectionString: databaseUrl, + ssl: false, + max: 10, + }); + + this.db = drizzle(this.pool, { schema }); + } + + public static getInstance(): TestDatabase { + if (!TestDatabase.instance) { + TestDatabase.instance = new TestDatabase(); + } + return TestDatabase.instance; + } + + public getDb(): NodePgDatabase { + return this.db; + } + + public async close(): Promise { + await this.pool.end(); + } + + private async safeDelete(table: any): Promise { + const tableName = getTableName(table); + try { + await this.db.delete(table); + } catch (error: any) { + const message = error?.message || ""; + if (typeof message === "string" && message.includes("does not exist")) { + console.warn( + `[TestDatabase] Tabla ${tableName} no existe en la BD de test, se omite.` + ); + return; + } + throw error; + } + } + + /** + * Limpia todas las tablas en orden correcto (respetando foreign keys) + */ + public async cleanDatabase(): Promise { + // Orden de eliminación respetando foreign keys + // Primero eliminar tablas que referencian a otras + await this.safeDelete(schema.goal_contribution_schedule); + await this.safeDelete(schema.transactions); // Referencia a goal_contributions, debts, budgets, etc. + await this.safeDelete(schema.goal_contributions); // Después de transactions + await this.safeDelete(schema.scheduled_transactions); + await this.safeDelete(schema.debts); + await this.safeDelete(schema.budgets); + await this.safeDelete(schema.notifications); + await this.safeDelete(schema.recommendations); + await this.safeDelete(schema.goals); + await this.safeDelete(schema.payment_methods); + await this.safeDelete(schema.friends); + await this.safeDelete(schema.users); + // categories no se eliminan porque son datos de referencia + } + + /** + * Verifica conexión a la base de datos + */ + public async checkConnection(): Promise { + try { + const client = await this.pool.connect(); + await client.query("SELECT NOW()"); + client.release(); + return true; + } catch (error) { + console.error("Error connecting to test database:", error); + return false; + } + } +} + +export const testDb = TestDatabase.getInstance(); diff --git a/tests/helpers/test-helpers.ts b/tests/helpers/test-helpers.ts new file mode 100644 index 0000000..2820656 --- /dev/null +++ b/tests/helpers/test-helpers.ts @@ -0,0 +1,105 @@ +import { hash } from "@/shared/utils/crypto.util"; +import { generateToken } from "@/shared/utils/jwt.util"; +import { TestDatabase } from "./test-db"; +import { users } from "@/core/infrastructure/database/schema"; +import { IUser } from "@/features/users/domain/entities/IUser"; + +const testDb = TestDatabase.getInstance(); + +/** + * Factory para crear usuarios de prueba + */ +export async function createTestUser( + overrides?: Partial<{ + name: string; + username: string; + email: string; + password: string; + active: boolean; + }> +): Promise { + const db = testDb.getDb(); + const password = overrides?.password || "TestPassword123!"; + const passwordHash = await hash(password); + + const [user] = await db + .insert(users) + .values({ + name: overrides?.name || "Test User", + username: overrides?.username || `testuser_${Date.now()}`, + email: overrides?.email || `test_${Date.now()}@example.com`, + password_hash: passwordHash, + active: overrides?.active !== undefined ? overrides.active : true, + }) + .returning(); + + return { + id: user.id, + name: user.name, + username: user.username, + email: user.email, + passwordHash: user.password_hash, + active: user.active, + registration_date: user.registration_date, + recoveryToken: user.recovery_token || null, + recoveryTokenExpires: user.recovery_token_expires || null, + }; +} + +/** + * Crea un token JWT válido para un usuario + */ +export async function createAuthToken( + userId: number, + email: string +): Promise { + return await generateToken({ id: userId, email }); +} + +/** + * Helper para hacer requests HTTP en tests de integración + */ +export async function makeRequest( + app: any, + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + path: string, + options?: { + body?: any; + headers?: Record; + query?: Record; + } +): Promise { + const url = new URL(path, "http://localhost"); + if (options?.query) { + Object.entries(options.query).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + } + + const request = new Request(url.toString(), { + method, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + body: options?.body ? JSON.stringify(options.body) : undefined, + }); + + return await app.fetch(request); +} + +/** + * Helper para parsear respuesta JSON + */ +export async function parseJsonResponse(response: Response): Promise { + return await response.json(); +} + +/** + * Helper para esperar un tiempo (útil para tests de timing) + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export { testDb }; diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts new file mode 100644 index 0000000..2c38424 --- /dev/null +++ b/tests/integration/auth.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest"; +import app from "@/app"; +import { + testDb, + createTestUser, + createAuthToken, +} from "../helpers/test-helpers"; +import { makeRequest, parseJsonResponse } from "../helpers/test-helpers"; + +describe("Auth Integration Tests", () => { + let testUser: any; + let authToken: string; + + beforeAll(async () => { + // Verificar conexión a la base de datos + const isConnected = await testDb.checkConnection(); + if (!isConnected) { + throw new Error("No se pudo conectar a la base de datos de pruebas"); + } + }); + + beforeEach(async () => { + // Limpiar base de datos antes de cada test + await testDb.cleanDatabase(); + + // Crear usuario de prueba + testUser = await createTestUser({ + email: "test@example.com", + password: "TestPassword123!", + }); + + authToken = await createAuthToken(testUser.id, testUser.email); + }); + + describe("POST /auth/login", () => { + it("debe hacer login exitoso con credenciales válidas", async () => { + const response = await makeRequest(app, "POST", "/auth/login", { + body: { + email: "test@example.com", + password: "TestPassword123!", + }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toBeDefined(); + expect(json.data.token).toBeDefined(); + expect(json.data.email).toBe("test@example.com"); + expect(json.data.id).toBe(testUser.id); + expect(json.message).toBe("Login successful"); + }); + + it("debe rechazar login con email incorrecto", async () => { + const response = await makeRequest(app, "POST", "/auth/login", { + body: { + email: "nonexistent@example.com", + password: "TestPassword123!", + }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + expect(json.message).toBe("Invalid credentials"); + }); + + it("debe rechazar login con contraseña incorrecta", async () => { + const response = await makeRequest(app, "POST", "/auth/login", { + body: { + email: "test@example.com", + password: "WrongPassword123!", + }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + expect(json.message).toBe("Invalid credentials"); + }); + }); + + describe("POST /auth/register", () => { + it("debe registrar un nuevo usuario exitosamente", async () => { + const response = await makeRequest(app, "POST", "/auth/register", { + body: { + name: "New User", + username: "newuser", + email: "newuser@example.com", + password: "NewPassword123!", + }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(201); + expect(json.success).toBe(true); + expect(json.data).toBeDefined(); + expect(json.data.token).toBeDefined(); + expect(json.data.email).toBe("newuser@example.com"); + expect(json.data.username).toBe("newuser"); + expect(json.message).toBe("Registration successful"); + }); + + it("debe rechazar registro con email duplicado", async () => { + const response = await makeRequest(app, "POST", "/auth/register", { + body: { + name: "Duplicate User", + username: "duplicateuser", + email: "test@example.com", // Email ya existe + password: "TestPassword123!", + }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(409); + expect(json.success).toBe(false); + expect(json.message).toBe("Email already exists"); + }); + + it("debe rechazar registro con username duplicado", async () => { + const response = await makeRequest(app, "POST", "/auth/register", { + body: { + name: "Duplicate User", + username: testUser.username, // Username ya existe + email: "different@example.com", + password: "TestPassword123!", + }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(409); + expect(json.success).toBe(false); + expect(json.message).toBe("Username already exists"); + }); + + it("debe validar formato de contraseña", async () => { + const response = await makeRequest(app, "POST", "/auth/register", { + body: { + name: "Test User", + username: "testuser2", + email: "test2@example.com", + password: "weak", // Contraseña débil + }, + }); + + // Debe fallar la validación de Zod + expect(response.status).toBeGreaterThanOrEqual(400); + }); + }); + + describe("POST /auth/forgot-password", () => { + it("debe procesar solicitud de recuperación de contraseña", async () => { + const response = await makeRequest(app, "POST", "/auth/forgot-password", { + body: { + email: "test@example.com", + }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(200); + expect(json.message).toContain("recovery link will be sent"); + }); + + it("debe retornar éxito incluso si el email no existe (seguridad)", async () => { + const response = await makeRequest(app, "POST", "/auth/forgot-password", { + body: { + email: "nonexistent@example.com", + }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(200); + expect(json.message).toContain("recovery link will be sent"); + }); + }); +}); + diff --git a/tests/integration/recommendations.test.ts b/tests/integration/recommendations.test.ts new file mode 100644 index 0000000..f3b2a41 --- /dev/null +++ b/tests/integration/recommendations.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeEach, beforeAll } from "vitest"; +import app from "@/app"; +import { + testDb, + createTestUser, + createAuthToken, +} from "../helpers/test-helpers"; +import { makeRequest, parseJsonResponse } from "../helpers/test-helpers"; +import { recommendations } from "@/core/infrastructure/database/schema"; +import { testDb as dbHelper } from "../helpers/test-db"; +import { eq } from "drizzle-orm"; + +describe("Recommendations Integration Tests", () => { + let testUser: any; + let authToken: string; + + beforeAll(async () => { + const isConnected = await testDb.checkConnection(); + if (!isConnected) { + throw new Error("No se pudo conectar a la base de datos de pruebas"); + } + }); + + beforeEach(async () => { + await testDb.cleanDatabase(); + + testUser = await createTestUser({ + email: "test@example.com", + password: "TestPassword123!", + }); + + authToken = await createAuthToken(testUser.id, testUser.email); + }); + + describe("GET /recommendations", () => { + it("debe obtener recomendaciones pendientes del usuario", async () => { + // Crear una recomendación de prueba + const db = dbHelper.getDb(); + await db.insert(recommendations).values({ + user_id: testUser.id, + title: "Test Recommendation", + description: "This is a test recommendation", + type: "FINANCIAL_TIP", + priority: "MEDIUM", + status: "PENDING", + }); + + const response = await makeRequest(app, "GET", "/recommendations", { + query: { userId: testUser.id.toString() }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toBeDefined(); + expect(Array.isArray(json.data)).toBe(true); + // Puede que el repositorio filtre por status "PENDING" (mayúsculas) + if (json.data.length > 0) { + expect(json.data[0].title).toBe("Test Recommendation"); + } + }); + + it("debe retornar error si no se proporciona userId", async () => { + const response = await makeRequest(app, "GET", "/recommendations", {}); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(401); + expect(json.success).toBe(false); + expect(json.message).toContain("User ID"); + }); + + it("debe retornar array vacío si no hay recomendaciones pendientes", async () => { + const response = await makeRequest(app, "GET", "/recommendations", { + query: { userId: testUser.id.toString() }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toBeDefined(); + expect(Array.isArray(json.data)).toBe(true); + expect(json.data.length).toBe(0); + }); + }); + + describe("PATCH /recommendations/:id/view", () => { + it("debe marcar una recomendación como vista", async () => { + // Crear una recomendación de prueba + const db = dbHelper.getDb(); + const [rec] = await db + .insert(recommendations) + .values({ + user_id: testUser.id, + title: "Test Recommendation", + description: "This is a test recommendation", + type: "FINANCIAL_TIP", + priority: "MEDIUM", + status: "PENDING", + }) + .returning(); + + const response = await makeRequest( + app, + "PATCH", + `/recommendations/${rec.id}/view`, + {} + ); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.message).toBe("Recommendation marked as viewed"); + + // Verificar que se actualizó en la base de datos + const updatedRec = await db + .select() + .from(recommendations) + .where(eq(recommendations.id, rec.id)); + // El status puede estar en mayúsculas + expect(updatedRec[0].status?.toUpperCase()).toBe("VIEWED"); + }); + + it("debe retornar error si la recomendación no existe", async () => { + const response = await makeRequest( + app, + "PATCH", + "/recommendations/99999/view", + {} + ); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(404); + expect(json.success).toBe(false); + expect(json.message).toBe("Recommendation not found"); + }); + }); + + describe("PATCH /recommendations/:id/dismiss", () => { + it("debe descartar una recomendación", async () => { + const db = dbHelper.getDb(); + const [rec] = await db + .insert(recommendations) + .values({ + user_id: testUser.id, + title: "Test Recommendation", + description: "This is a test recommendation", + type: "FINANCIAL_TIP", + priority: "MEDIUM", + status: "PENDING", + }) + .returning(); + + const response = await makeRequest( + app, + "PATCH", + `/recommendations/${rec.id}/dismiss`, + {} + ); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.message).toBe("Recommendation dismissed successfully"); + }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..cd44ac6 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,113 @@ +import path from "node:path"; +import { existsSync } from "node:fs"; +import { config } from "dotenv"; +import { expand } from "dotenv-expand"; +import { execSync } from "node:child_process"; + +/** + * Setup global para tests + * Asegura que NODE_ENV=test esté configurado antes de cualquier import + * + * ⚠️ IMPORTANTE: Este archivo se ejecuta ANTES de cualquier import + * para asegurar que se use la configuración de test correcta + */ + +const envTestPath = path.resolve(process.cwd(), ".env.test"); + +if (existsSync(envTestPath)) { + expand( + config({ + path: envTestPath, + }) + ); +} else { + expand(config()); +} + +// Configurar NODE_ENV=test ANTES de cualquier otra cosa +if (process.env.NODE_ENV !== "test") { + process.env.NODE_ENV = "test"; + console.log("🔧 NODE_ENV configurado a 'test'"); +} + +// Asegurar que se use una base de datos de test diferente +if (!process.env.TEST_DATABASE_URL) { + if (process.env.DATABASE_URL) { + const mainUrl = new URL(process.env.DATABASE_URL); + const mainDbName = mainUrl.pathname.replace("/", ""); + + // Si la BD principal no termina en _test, crear una nueva URL con _test + if (!mainDbName.endsWith("_test") && !mainDbName.includes("test")) { + mainUrl.pathname = `/${mainDbName}_test`; + process.env.TEST_DATABASE_URL = mainUrl.toString(); + console.log( + "⚠️ TEST_DATABASE_URL no configurado, usando:", + mainUrl.pathname + ); + console.log( + "⚠️ Configura TEST_DATABASE_URL en .env.test para mayor seguridad" + ); + } else { + // Si ya tiene test en el nombre, usarla directamente + process.env.TEST_DATABASE_URL = process.env.DATABASE_URL; + } + } else { + console.warn( + "⚠️ TEST_DATABASE_URL no configurado y DATABASE_URL no encontrado." + ); + } +} + +// Forzar a que la app use la base de datos de test +if (process.env.TEST_DATABASE_URL) { + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; +} + +console.log("🧪 Entorno de test configurado"); +console.log("📦 NODE_ENV:", process.env.NODE_ENV); +if (process.env.TEST_DATABASE_URL) { + const testUrl = new URL(process.env.TEST_DATABASE_URL); + console.log("🗄️ Base de datos de test:", testUrl.pathname); + + // Advertencia si el nombre no contiene "test" + if (!testUrl.pathname.toLowerCase().includes("test")) { + console.error( + "❌ ADVERTENCIA: El nombre de la BD de test no contiene 'test'!" + ); + console.error("❌ Esto podría ser peligroso. Verifica tu configuración."); + } +} else { + console.warn("⚠️ TEST_DATABASE_URL no está configurado"); + console.warn("⚠️ Crea un archivo .env.test con TEST_DATABASE_URL"); +} + +const ensureMigrations = () => { + const flag = "__TEST_MIGRATIONS_APPLIED__"; + if ((globalThis as Record)[flag]) { + return; + } + + if (!process.env.DATABASE_URL) { + console.warn( + "⚠️ No se ejecutarán migraciones porque DATABASE_URL no está definido." + ); + return; + } + + const drizzleConfigPath = path.resolve(process.cwd(), "drizzle.config.ts"); + const command = `bunx drizzle-kit migrate --config ${drizzleConfigPath}`; + + try { + execSync(command, { + stdio: "inherit", + env: process.env, + }); + console.log("✅ Migraciones de prueba aplicadas correctamente"); + (globalThis as Record)[flag] = true; + } catch (error) { + console.error("❌ Error aplicando migraciones de prueba:", error); + throw error; + } +}; + +ensureMigrations(); diff --git a/tests/unit/services/auth.service.test.ts b/tests/unit/services/auth.service.test.ts new file mode 100644 index 0000000..a9d8fe8 --- /dev/null +++ b/tests/unit/services/auth.service.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { AuthService } from "@/features/auth/application/services/auth.service"; +import { IUserRepository } from "@/features/users/domain/ports/user-repository.port"; +import { IUser } from "@/features/users/domain/entities/IUser"; +import { hash } from "@/shared/utils/crypto.util"; +import { createTestUser, testDb } from "../../helpers/test-helpers"; +import { PgUserRepository } from "@/features/users/infrastructure/adapters/user.repository"; + +describe("AuthService", () => { + let authService: AuthService; + let userRepository: IUserRepository; + let testUser: IUser; + + beforeEach(async () => { + // Limpiar base de datos antes de cada test + await testDb.cleanDatabase(); + + // Usar repositorio real para tests más realistas + userRepository = PgUserRepository.getInstance(); + authService = AuthService.getInstance(userRepository); + + // Crear usuario de prueba + testUser = await createTestUser({ + email: "test@example.com", + password: "TestPassword123!", + }); + }); + + describe("login", () => { + it("debe hacer login exitoso con credenciales válidas", async () => { + const mockContext = createMockContext({ + email: "test@example.com", + password: "TestPassword123!", + }); + + const response = await authService.login(mockContext); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toBeDefined(); + expect(json.data.token).toBeDefined(); + expect(json.data.email).toBe(testUser.email); + expect(json.message).toBe("Login successful"); + }); + + it("debe rechazar login con email incorrecto", async () => { + const mockContext = createMockContext({ + email: "nonexistent@example.com", + password: "TestPassword123!", + }); + + const response = await authService.login(mockContext); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + expect(json.message).toBe("Invalid credentials"); + }); + + it("debe rechazar login con contraseña incorrecta", async () => { + const mockContext = createMockContext({ + email: "test@example.com", + password: "WrongPassword123!", + }); + + const response = await authService.login(mockContext); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + expect(json.message).toBe("Invalid credentials"); + }); + + it("debe rechazar login con usuario inactivo", async () => { + // Crear usuario inactivo + const inactiveUser = await createTestUser({ + email: "inactive@example.com", + password: "TestPassword123!", + active: false, + }); + + const mockContext = createMockContext({ + email: "inactive@example.com", + password: "TestPassword123!", + }); + + const response = await authService.login(mockContext); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + expect(json.message).toBe("Invalid credentials"); + }); + }); + + describe("register", () => { + it("debe registrar un nuevo usuario exitosamente", async () => { + const mockContext = createMockContext({ + name: "New User", + username: "newuser", + email: "newuser@example.com", + password: "NewPassword123!", + }); + + const response = await authService.register(mockContext); + const json = await response.json(); + + expect(response.status).toBe(201); + expect(json.success).toBe(true); + expect(json.data).toBeDefined(); + expect(json.data.token).toBeDefined(); + expect(json.data.email).toBe("newuser@example.com"); + expect(json.data.username).toBe("newuser"); + expect(json.message).toBe("Registration successful"); + }); + + it("debe rechazar registro con email duplicado", async () => { + const mockContext = createMockContext({ + name: "Duplicate User", + username: "duplicateuser", + email: "test@example.com", // Email ya existe + password: "TestPassword123!", + }); + + const response = await authService.register(mockContext); + const json = await response.json(); + + expect(response.status).toBe(409); + expect(json.success).toBe(false); + expect(json.message).toBe("Email already exists"); + }); + + it("debe rechazar registro con username duplicado", async () => { + const mockContext = createMockContext({ + name: "Duplicate User", + username: testUser.username, // Username ya existe + email: "different@example.com", + password: "TestPassword123!", + }); + + const response = await authService.register(mockContext); + const json = await response.json(); + + expect(response.status).toBe(409); + expect(json.success).toBe(false); + expect(json.message).toBe("Username already exists"); + }); + }); + + describe("forgotPassword", () => { + it("debe generar token de recuperación para email válido", async () => { + const mockContext = createMockContext({ + email: "test@example.com", + }); + + const response = await authService.forgotPassword(mockContext); + const json = await response.json(); + + expect(response.status).toBe(200); + // El servicio puede retornar success: false si el email falla, pero el token se genera + // Verificamos que el token se guardó correctamente + const updatedUser = await userRepository.findByEmail("test@example.com"); + expect(updatedUser?.recoveryToken).toBeDefined(); + expect(updatedUser?.recoveryTokenExpires).toBeDefined(); + expect(json.message).toContain("recovery link will be sent"); + }); + + it("debe retornar éxito incluso si el email no existe (seguridad)", async () => { + const mockContext = createMockContext({ + email: "nonexistent@example.com", + }); + + const response = await authService.forgotPassword(mockContext); + const json = await response.json(); + + expect(response.status).toBe(200); + // El servicio retorna success: false cuando el usuario no existe, pero status 200 por seguridad + expect(json.message).toContain("recovery link will be sent"); + }); + }); + + describe("resetPassword", () => { + it("debe resetear contraseña con token válido", async () => { + // Primero generar token de recuperación + const forgotContext = createMockContext({ + email: "test@example.com", + }); + await authService.forgotPassword(forgotContext); + + // Obtener el token generado + const user = await userRepository.findByEmail("test@example.com"); + const token = user?.recoveryToken; + expect(token).toBeDefined(); + + // Resetear contraseña + const resetContext = createMockContext({ + token: token!, + password: "NewPassword123!", + }); + + const response = await authService.resetPassword(resetContext); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.message).toBe("Password has been reset successfully"); + + // Verificar que el token fue limpiado + const updatedUser = await userRepository.findByEmail("test@example.com"); + expect(updatedUser?.recoveryToken).toBeNull(); + + // Verificar que la contraseña fue actualizada (intentando login) + const loginContext = createMockContext({ + email: "test@example.com", + password: "NewPassword123!", + }); + const loginResponse = await authService.login(loginContext); + const loginJson = await loginResponse.json(); + expect(loginJson.success).toBe(true); + }); + + it("debe rechazar reset con token inválido", async () => { + const mockContext = createMockContext({ + token: "invalid_token_12345", + password: "NewPassword123!", + }); + + const response = await authService.resetPassword(mockContext); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + expect(json.message).toBe("Invalid or expired token"); + }); + }); +}); + +/** + * Helper para crear un mock context de Hono + */ +function createMockContext(body: any): any { + return { + req: { + valid: (type: string) => { + if (type === "json") { + return body; + } + return body; + }, + }, + json: (data: any, status: number = 200) => { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); + }, + }; +} diff --git a/tests/unit/utils/crypto.util.test.ts b/tests/unit/utils/crypto.util.test.ts new file mode 100644 index 0000000..f2560e6 --- /dev/null +++ b/tests/unit/utils/crypto.util.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { hash, verify } from "@/shared/utils/crypto.util"; + +describe("crypto.util", () => { + describe("hash", () => { + it("debe generar un hash válido para una contraseña", async () => { + const password = "TestPassword123!"; + const hashed = await hash(password); + + expect(hashed).toBeDefined(); + expect(typeof hashed).toBe("string"); + expect(hashed.length).toBeGreaterThan(0); + expect(hashed).not.toBe(password); + }); + + it("debe generar hashes diferentes para la misma contraseña (sal aleatoria)", async () => { + const password = "TestPassword123!"; + const hash1 = await hash(password); + const hash2 = await hash(password); + + // Los hashes deben ser diferentes debido al salt + expect(hash1).not.toBe(hash2); + }); + }); + + describe("verify", () => { + it("debe verificar correctamente una contraseña válida", async () => { + const password = "TestPassword123!"; + const hashed = await hash(password); + + const isValid = await verify(password, hashed); + expect(isValid).toBe(true); + }); + + it("debe rechazar una contraseña incorrecta", async () => { + const password = "TestPassword123!"; + const wrongPassword = "WrongPassword123!"; + const hashed = await hash(password); + + const isValid = await verify(wrongPassword, hashed); + expect(isValid).toBe(false); + }); + + it("debe rechazar un hash inválido", async () => { + const password = "TestPassword123!"; + const invalidHash = "invalid_hash_string"; + + try { + const isValid = await verify(password, invalidHash); + expect(isValid).toBe(false); + } catch (error) { + // Bun.password.verify lanza error con hash inválido, lo cual es el comportamiento esperado + expect(error).toBeDefined(); + } + }); + }); +}); diff --git a/tests/unit/utils/jwt.util.test.ts b/tests/unit/utils/jwt.util.test.ts new file mode 100644 index 0000000..244a4ef --- /dev/null +++ b/tests/unit/utils/jwt.util.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { generateToken, verifyToken } from "@/shared/utils/jwt.util"; + +describe("jwt.util", () => { + // Asegurar que JWT_SECRET esté configurado + beforeAll(() => { + if (!process.env.JWT_SECRET) { + process.env.JWT_SECRET = + "test-secret-key-for-jwt-testing-minimum-32-chars"; + } + }); + + describe("generateToken", () => { + it("debe generar un token JWT válido", async () => { + const payload = { id: 1, email: "test@example.com" }; + const token = await generateToken(payload); + + expect(token).toBeDefined(); + expect(typeof token).toBe("string"); + expect(token.split(".").length).toBe(3); // JWT tiene 3 partes separadas por puntos + }); + + it("debe generar tokens diferentes para diferentes payloads", async () => { + const payload1 = { id: 1, email: "test1@example.com" }; + const payload2 = { id: 2, email: "test2@example.com" }; + + const token1 = await generateToken(payload1); + const token2 = await generateToken(payload2); + + expect(token1).not.toBe(token2); + }); + + it("debe incluir el payload en el token", async () => { + const payload = { id: 123, email: "user@example.com" }; + const token = await generateToken(payload); + const verified = await verifyToken(token); + + expect(verified).toBeDefined(); + expect(verified?.id).toBe(payload.id); + expect(verified?.email).toBe(payload.email); + }); + }); + + describe("verifyToken", () => { + it("debe verificar correctamente un token válido", async () => { + const payload = { id: 1, email: "test@example.com" }; + const token = await generateToken(payload); + const verified = await verifyToken(token); + + expect(verified).toBeDefined(); + expect(verified?.id).toBe(payload.id); + expect(verified?.email).toBe(payload.email); + }); + + it("debe rechazar un token inválido", async () => { + const invalidToken = "invalid.token.string"; + const verified = await verifyToken(invalidToken); + + expect(verified).toBeNull(); + }); + + it("debe rechazar un token malformado", async () => { + const malformedToken = "not.a.valid.jwt.token"; + const verified = await verifyToken(malformedToken); + + expect(verified).toBeNull(); + }); + + it("debe rechazar un token vacío", async () => { + const emptyToken = ""; + const verified = await verifyToken(emptyToken); + + expect(verified).toBeNull(); + }); + }); +}); + diff --git a/tsconfig.json b/tsconfig.json index 70f3468..5c52b68 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,9 @@ "target": "esnext", "module": "esnext", "paths": { + "@/*": [ + "./src/*" + ], "@/env": [ "./src/core/infrastructure/env/env.ts" ], @@ -58,6 +61,9 @@ ], "@/notifications/*": [ "./src/features/notifications/*" + ], + "@/subscriptions/*": [ + "./src/features/subscriptions/*" ] } } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a58da7a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + globals: true, + environment: "node", + setupFiles: ["tests/setup.ts"], + fileParallelism: false, // Run test files sequentially to avoid database conflicts + include: [ + "src/**/*.test.ts", + "src/**/*.spec.ts", + "tests/**/*.test.ts", + "tests/**/*.spec.ts", + ], + }, +});