From 12fb6eeee80d837d141cdf3236fa0126a80cff11 Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Sun, 4 Jan 2026 06:40:14 -0600 Subject: [PATCH] Initial commit - erp-core-backend --- .env.example | 22 + .gitignore | 32 + Dockerfile | 52 + TYPEORM_DEPENDENCIES.md | 78 + TYPEORM_INTEGRATION_SUMMARY.md | 302 + TYPEORM_USAGE_EXAMPLES.md | 536 + package-lock.json | 8585 +++++++++++++++++ package.json | 59 + service.descriptor.yml | 134 + src/app.ts | 112 + src/config/database.ts | 69 + src/config/index.ts | 35 + src/config/redis.ts | 178 + src/config/swagger.config.ts | 200 + src/config/typeorm.ts | 215 + src/docs/openapi.yaml | 138 + src/index.ts | 71 + src/modules/auth/apiKeys.controller.ts | 331 + src/modules/auth/apiKeys.routes.ts | 56 + src/modules/auth/apiKeys.service.ts | 491 + src/modules/auth/auth.controller.ts | 192 + src/modules/auth/auth.routes.ts | 18 + src/modules/auth/auth.service.ts | 234 + src/modules/auth/entities/api-key.entity.ts | 87 + src/modules/auth/entities/company.entity.ts | 93 + src/modules/auth/entities/group.entity.ts | 89 + src/modules/auth/entities/index.ts | 15 + .../auth/entities/mfa-audit-log.entity.ts | 87 + .../auth/entities/oauth-provider.entity.ts | 191 + .../auth/entities/oauth-state.entity.ts | 66 + .../auth/entities/oauth-user-link.entity.ts | 73 + .../auth/entities/password-reset.entity.ts | 45 + .../auth/entities/permission.entity.ts | 52 + src/modules/auth/entities/role.entity.ts | 84 + src/modules/auth/entities/session.entity.ts | 90 + src/modules/auth/entities/tenant.entity.ts | 93 + .../auth/entities/trusted-device.entity.ts | 115 + src/modules/auth/entities/user.entity.ts | 141 + .../auth/entities/verification-code.entity.ts | 90 + src/modules/auth/index.ts | 8 + src/modules/auth/services/token.service.ts | 456 + src/modules/companies/companies.controller.ts | 241 + src/modules/companies/companies.routes.ts | 50 + src/modules/companies/companies.service.ts | 472 + src/modules/companies/index.ts | 3 + src/modules/core/core.controller.ts | 257 + src/modules/core/core.routes.ts | 51 + src/modules/core/countries.service.ts | 45 + src/modules/core/currencies.service.ts | 118 + src/modules/core/entities/country.entity.ts | 35 + src/modules/core/entities/currency.entity.ts | 43 + src/modules/core/entities/index.ts | 6 + .../core/entities/product-category.entity.ts | 79 + src/modules/core/entities/sequence.entity.ts | 83 + .../core/entities/uom-category.entity.ts | 30 + src/modules/core/entities/uom.entity.ts | 76 + src/modules/core/index.ts | 8 + .../core/product-categories.service.ts | 223 + src/modules/core/sequences.service.ts | 466 + src/modules/core/uom.service.ts | 162 + src/modules/crm/crm.controller.ts | 682 ++ src/modules/crm/crm.routes.ts | 126 + src/modules/crm/index.ts | 5 + src/modules/crm/leads.service.ts | 449 + src/modules/crm/opportunities.service.ts | 503 + src/modules/crm/stages.service.ts | 435 + src/modules/financial/MIGRATION_GUIDE.md | 612 ++ src/modules/financial/accounts.service.old.ts | 330 + src/modules/financial/accounts.service.ts | 468 + .../financial/entities/account-type.entity.ts | 38 + .../financial/entities/account.entity.ts | 93 + .../entities/fiscal-period.entity.ts | 64 + .../financial/entities/fiscal-year.entity.ts | 67 + src/modules/financial/entities/index.ts | 22 + .../financial/entities/invoice-line.entity.ts | 79 + .../financial/entities/invoice.entity.ts | 152 + .../entities/journal-entry-line.entity.ts | 59 + .../entities/journal-entry.entity.ts | 104 + .../financial/entities/journal.entity.ts | 94 + .../financial/entities/payment.entity.ts | 135 + src/modules/financial/entities/tax.entity.ts | 78 + src/modules/financial/financial.controller.ts | 753 ++ src/modules/financial/financial.routes.ts | 150 + .../financial/fiscalPeriods.service.ts | 369 + src/modules/financial/index.ts | 8 + src/modules/financial/invoices.service.ts | 547 ++ .../financial/journal-entries.service.ts | 343 + src/modules/financial/journals.service.old.ts | 216 + src/modules/financial/journals.service.ts | 216 + src/modules/financial/payments.service.ts | 456 + src/modules/financial/taxes.service.old.ts | 382 + src/modules/financial/taxes.service.ts | 382 + src/modules/hr/contracts.service.ts | 346 + src/modules/hr/departments.service.ts | 393 + src/modules/hr/employees.service.ts | 402 + src/modules/hr/hr.controller.ts | 721 ++ src/modules/hr/hr.routes.ts | 152 + src/modules/hr/index.ts | 6 + src/modules/hr/leaves.service.ts | 517 + src/modules/inventory/MIGRATION_STATUS.md | 177 + src/modules/inventory/adjustments.service.ts | 512 + src/modules/inventory/entities/index.ts | 11 + .../inventory-adjustment-line.entity.ts | 80 + .../entities/inventory-adjustment.entity.ts | 86 + .../inventory/entities/location.entity.ts | 96 + src/modules/inventory/entities/lot.entity.ts | 64 + .../inventory/entities/picking.entity.ts | 125 + .../inventory/entities/product.entity.ts | 154 + .../inventory/entities/stock-move.entity.ts | 104 + .../inventory/entities/stock-quant.entity.ts | 66 + .../entities/stock-valuation-layer.entity.ts | 85 + .../inventory/entities/warehouse.entity.ts | 68 + src/modules/inventory/index.ts | 16 + src/modules/inventory/inventory.controller.ts | 875 ++ src/modules/inventory/inventory.routes.ts | 174 + src/modules/inventory/locations.service.ts | 212 + src/modules/inventory/lots.service.ts | 263 + src/modules/inventory/pickings.service.ts | 357 + src/modules/inventory/products.service.ts | 410 + src/modules/inventory/valuation.controller.ts | 230 + src/modules/inventory/valuation.service.ts | 522 + src/modules/inventory/warehouses.service.ts | 283 + src/modules/partners/entities/index.ts | 1 + .../partners/entities/partner.entity.ts | 132 + src/modules/partners/index.ts | 6 + src/modules/partners/partners.controller.ts | 333 + src/modules/partners/partners.routes.ts | 90 + src/modules/partners/partners.service.ts | 395 + src/modules/partners/ranking.controller.ts | 368 + src/modules/partners/ranking.service.ts | 431 + src/modules/projects/index.ts | 5 + src/modules/projects/projects.controller.ts | 569 ++ src/modules/projects/projects.routes.ts | 75 + src/modules/projects/projects.service.ts | 309 + src/modules/projects/tasks.service.ts | 293 + src/modules/projects/timesheets.service.ts | 302 + src/modules/purchases/index.ts | 4 + src/modules/purchases/purchases.controller.ts | 352 + src/modules/purchases/purchases.routes.ts | 90 + src/modules/purchases/purchases.service.ts | 386 + src/modules/purchases/rfqs.service.ts | 485 + src/modules/reports/index.ts | 3 + src/modules/reports/reports.controller.ts | 434 + src/modules/reports/reports.routes.ts | 96 + src/modules/reports/reports.service.ts | 580 ++ src/modules/roles/index.ts | 13 + src/modules/roles/permissions.controller.ts | 218 + src/modules/roles/permissions.routes.ts | 55 + src/modules/roles/permissions.service.ts | 342 + src/modules/roles/roles.controller.ts | 292 + src/modules/roles/roles.routes.ts | 57 + src/modules/roles/roles.service.ts | 454 + src/modules/sales/customer-groups.service.ts | 209 + src/modules/sales/index.ts | 7 + src/modules/sales/orders.service.ts | 707 ++ src/modules/sales/pricelists.service.ts | 249 + src/modules/sales/quotations.service.ts | 588 ++ src/modules/sales/sales-teams.service.ts | 241 + src/modules/sales/sales.controller.ts | 889 ++ src/modules/sales/sales.routes.ts | 159 + src/modules/system/activities.service.ts | 350 + src/modules/system/index.ts | 5 + src/modules/system/messages.service.ts | 234 + src/modules/system/notifications.service.ts | 227 + src/modules/system/system.controller.ts | 404 + src/modules/system/system.routes.ts | 48 + src/modules/tenants/index.ts | 7 + src/modules/tenants/tenants.controller.ts | 315 + src/modules/tenants/tenants.routes.ts | 69 + src/modules/tenants/tenants.service.ts | 449 + src/modules/users/index.ts | 3 + src/modules/users/users.controller.ts | 260 + src/modules/users/users.routes.ts | 60 + src/modules/users/users.service.ts | 372 + src/shared/errors/index.ts | 18 + .../middleware/apiKeyAuth.middleware.ts | 217 + src/shared/middleware/auth.middleware.ts | 119 + .../middleware/fieldPermissions.middleware.ts | 343 + src/shared/services/base.service.ts | 429 + src/shared/services/index.ts | 7 + src/shared/types/index.ts | 144 + src/shared/utils/logger.ts | 40 + tsconfig.json | 30 + 183 files changed, 46756 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 TYPEORM_DEPENDENCIES.md create mode 100644 TYPEORM_INTEGRATION_SUMMARY.md create mode 100644 TYPEORM_USAGE_EXAMPLES.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 service.descriptor.yml create mode 100644 src/app.ts create mode 100644 src/config/database.ts create mode 100644 src/config/index.ts create mode 100644 src/config/redis.ts create mode 100644 src/config/swagger.config.ts create mode 100644 src/config/typeorm.ts create mode 100644 src/docs/openapi.yaml create mode 100644 src/index.ts create mode 100644 src/modules/auth/apiKeys.controller.ts create mode 100644 src/modules/auth/apiKeys.routes.ts create mode 100644 src/modules/auth/apiKeys.service.ts create mode 100644 src/modules/auth/auth.controller.ts create mode 100644 src/modules/auth/auth.routes.ts create mode 100644 src/modules/auth/auth.service.ts create mode 100644 src/modules/auth/entities/api-key.entity.ts create mode 100644 src/modules/auth/entities/company.entity.ts create mode 100644 src/modules/auth/entities/group.entity.ts create mode 100644 src/modules/auth/entities/index.ts create mode 100644 src/modules/auth/entities/mfa-audit-log.entity.ts create mode 100644 src/modules/auth/entities/oauth-provider.entity.ts create mode 100644 src/modules/auth/entities/oauth-state.entity.ts create mode 100644 src/modules/auth/entities/oauth-user-link.entity.ts create mode 100644 src/modules/auth/entities/password-reset.entity.ts create mode 100644 src/modules/auth/entities/permission.entity.ts create mode 100644 src/modules/auth/entities/role.entity.ts create mode 100644 src/modules/auth/entities/session.entity.ts create mode 100644 src/modules/auth/entities/tenant.entity.ts create mode 100644 src/modules/auth/entities/trusted-device.entity.ts create mode 100644 src/modules/auth/entities/user.entity.ts create mode 100644 src/modules/auth/entities/verification-code.entity.ts create mode 100644 src/modules/auth/index.ts create mode 100644 src/modules/auth/services/token.service.ts create mode 100644 src/modules/companies/companies.controller.ts create mode 100644 src/modules/companies/companies.routes.ts create mode 100644 src/modules/companies/companies.service.ts create mode 100644 src/modules/companies/index.ts create mode 100644 src/modules/core/core.controller.ts create mode 100644 src/modules/core/core.routes.ts create mode 100644 src/modules/core/countries.service.ts create mode 100644 src/modules/core/currencies.service.ts create mode 100644 src/modules/core/entities/country.entity.ts create mode 100644 src/modules/core/entities/currency.entity.ts create mode 100644 src/modules/core/entities/index.ts create mode 100644 src/modules/core/entities/product-category.entity.ts create mode 100644 src/modules/core/entities/sequence.entity.ts create mode 100644 src/modules/core/entities/uom-category.entity.ts create mode 100644 src/modules/core/entities/uom.entity.ts create mode 100644 src/modules/core/index.ts create mode 100644 src/modules/core/product-categories.service.ts create mode 100644 src/modules/core/sequences.service.ts create mode 100644 src/modules/core/uom.service.ts create mode 100644 src/modules/crm/crm.controller.ts create mode 100644 src/modules/crm/crm.routes.ts create mode 100644 src/modules/crm/index.ts create mode 100644 src/modules/crm/leads.service.ts create mode 100644 src/modules/crm/opportunities.service.ts create mode 100644 src/modules/crm/stages.service.ts create mode 100644 src/modules/financial/MIGRATION_GUIDE.md create mode 100644 src/modules/financial/accounts.service.old.ts create mode 100644 src/modules/financial/accounts.service.ts create mode 100644 src/modules/financial/entities/account-type.entity.ts create mode 100644 src/modules/financial/entities/account.entity.ts create mode 100644 src/modules/financial/entities/fiscal-period.entity.ts create mode 100644 src/modules/financial/entities/fiscal-year.entity.ts create mode 100644 src/modules/financial/entities/index.ts create mode 100644 src/modules/financial/entities/invoice-line.entity.ts create mode 100644 src/modules/financial/entities/invoice.entity.ts create mode 100644 src/modules/financial/entities/journal-entry-line.entity.ts create mode 100644 src/modules/financial/entities/journal-entry.entity.ts create mode 100644 src/modules/financial/entities/journal.entity.ts create mode 100644 src/modules/financial/entities/payment.entity.ts create mode 100644 src/modules/financial/entities/tax.entity.ts create mode 100644 src/modules/financial/financial.controller.ts create mode 100644 src/modules/financial/financial.routes.ts create mode 100644 src/modules/financial/fiscalPeriods.service.ts create mode 100644 src/modules/financial/index.ts create mode 100644 src/modules/financial/invoices.service.ts create mode 100644 src/modules/financial/journal-entries.service.ts create mode 100644 src/modules/financial/journals.service.old.ts create mode 100644 src/modules/financial/journals.service.ts create mode 100644 src/modules/financial/payments.service.ts create mode 100644 src/modules/financial/taxes.service.old.ts create mode 100644 src/modules/financial/taxes.service.ts create mode 100644 src/modules/hr/contracts.service.ts create mode 100644 src/modules/hr/departments.service.ts create mode 100644 src/modules/hr/employees.service.ts create mode 100644 src/modules/hr/hr.controller.ts create mode 100644 src/modules/hr/hr.routes.ts create mode 100644 src/modules/hr/index.ts create mode 100644 src/modules/hr/leaves.service.ts create mode 100644 src/modules/inventory/MIGRATION_STATUS.md create mode 100644 src/modules/inventory/adjustments.service.ts create mode 100644 src/modules/inventory/entities/index.ts create mode 100644 src/modules/inventory/entities/inventory-adjustment-line.entity.ts create mode 100644 src/modules/inventory/entities/inventory-adjustment.entity.ts create mode 100644 src/modules/inventory/entities/location.entity.ts create mode 100644 src/modules/inventory/entities/lot.entity.ts create mode 100644 src/modules/inventory/entities/picking.entity.ts create mode 100644 src/modules/inventory/entities/product.entity.ts create mode 100644 src/modules/inventory/entities/stock-move.entity.ts create mode 100644 src/modules/inventory/entities/stock-quant.entity.ts create mode 100644 src/modules/inventory/entities/stock-valuation-layer.entity.ts create mode 100644 src/modules/inventory/entities/warehouse.entity.ts create mode 100644 src/modules/inventory/index.ts create mode 100644 src/modules/inventory/inventory.controller.ts create mode 100644 src/modules/inventory/inventory.routes.ts create mode 100644 src/modules/inventory/locations.service.ts create mode 100644 src/modules/inventory/lots.service.ts create mode 100644 src/modules/inventory/pickings.service.ts create mode 100644 src/modules/inventory/products.service.ts create mode 100644 src/modules/inventory/valuation.controller.ts create mode 100644 src/modules/inventory/valuation.service.ts create mode 100644 src/modules/inventory/warehouses.service.ts create mode 100644 src/modules/partners/entities/index.ts create mode 100644 src/modules/partners/entities/partner.entity.ts create mode 100644 src/modules/partners/index.ts create mode 100644 src/modules/partners/partners.controller.ts create mode 100644 src/modules/partners/partners.routes.ts create mode 100644 src/modules/partners/partners.service.ts create mode 100644 src/modules/partners/ranking.controller.ts create mode 100644 src/modules/partners/ranking.service.ts create mode 100644 src/modules/projects/index.ts create mode 100644 src/modules/projects/projects.controller.ts create mode 100644 src/modules/projects/projects.routes.ts create mode 100644 src/modules/projects/projects.service.ts create mode 100644 src/modules/projects/tasks.service.ts create mode 100644 src/modules/projects/timesheets.service.ts create mode 100644 src/modules/purchases/index.ts create mode 100644 src/modules/purchases/purchases.controller.ts create mode 100644 src/modules/purchases/purchases.routes.ts create mode 100644 src/modules/purchases/purchases.service.ts create mode 100644 src/modules/purchases/rfqs.service.ts create mode 100644 src/modules/reports/index.ts create mode 100644 src/modules/reports/reports.controller.ts create mode 100644 src/modules/reports/reports.routes.ts create mode 100644 src/modules/reports/reports.service.ts create mode 100644 src/modules/roles/index.ts create mode 100644 src/modules/roles/permissions.controller.ts create mode 100644 src/modules/roles/permissions.routes.ts create mode 100644 src/modules/roles/permissions.service.ts create mode 100644 src/modules/roles/roles.controller.ts create mode 100644 src/modules/roles/roles.routes.ts create mode 100644 src/modules/roles/roles.service.ts create mode 100644 src/modules/sales/customer-groups.service.ts create mode 100644 src/modules/sales/index.ts create mode 100644 src/modules/sales/orders.service.ts create mode 100644 src/modules/sales/pricelists.service.ts create mode 100644 src/modules/sales/quotations.service.ts create mode 100644 src/modules/sales/sales-teams.service.ts create mode 100644 src/modules/sales/sales.controller.ts create mode 100644 src/modules/sales/sales.routes.ts create mode 100644 src/modules/system/activities.service.ts create mode 100644 src/modules/system/index.ts create mode 100644 src/modules/system/messages.service.ts create mode 100644 src/modules/system/notifications.service.ts create mode 100644 src/modules/system/system.controller.ts create mode 100644 src/modules/system/system.routes.ts create mode 100644 src/modules/tenants/index.ts create mode 100644 src/modules/tenants/tenants.controller.ts create mode 100644 src/modules/tenants/tenants.routes.ts create mode 100644 src/modules/tenants/tenants.service.ts create mode 100644 src/modules/users/index.ts create mode 100644 src/modules/users/users.controller.ts create mode 100644 src/modules/users/users.routes.ts create mode 100644 src/modules/users/users.service.ts create mode 100644 src/shared/errors/index.ts create mode 100644 src/shared/middleware/apiKeyAuth.middleware.ts create mode 100644 src/shared/middleware/auth.middleware.ts create mode 100644 src/shared/middleware/fieldPermissions.middleware.ts create mode 100644 src/shared/services/base.service.ts create mode 100644 src/shared/services/index.ts create mode 100644 src/shared/types/index.ts create mode 100644 src/shared/utils/logger.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..26d8039 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Server +NODE_ENV=development +PORT=3011 +API_PREFIX=/api/v1 + +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=erp_generic +DB_USER=erp_admin +DB_PASSWORD=erp_secret_2024 + +# JWT +JWT_SECRET=your-super-secret-jwt-key-change-in-production +JWT_EXPIRES_IN=24h +JWT_REFRESH_EXPIRES_IN=7d + +# Logging +LOG_LEVEL=debug + +# CORS +CORS_ORIGIN=http://localhost:3010,http://localhost:5173 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22f4ec5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment files +.env +.env.local +.env.*.local + +# Logs +logs/ +*.log +npm-debug.log* + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Test coverage +coverage/ + +# Temporary files +tmp/ +temp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8376ee0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# ============================================================================= +# ERP-CORE Backend - Dockerfile +# ============================================================================= +# Multi-stage build for production +# ============================================================================= + +# Stage 1: Dependencies +FROM node:20-alpine AS deps +WORKDIR /app + +# Install dependencies needed for native modules +RUN apk add --no-cache libc6-compat python3 make g++ + +COPY package*.json ./ +RUN npm ci --only=production && npm cache clean --force + +# Stage 2: Builder +FROM node:20-alpine AS builder +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# Stage 3: Production +FROM node:20-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production + +# Create non-root user +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nestjs + +# Copy built application +COPY --from=deps /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/package*.json ./ + +# Create logs directory +RUN mkdir -p /var/log/erp-core && chown -R nestjs:nodejs /var/log/erp-core + +USER nestjs + +EXPOSE 3011 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3011/health || exit 1 + +CMD ["node", "dist/main.js"] diff --git a/TYPEORM_DEPENDENCIES.md b/TYPEORM_DEPENDENCIES.md new file mode 100644 index 0000000..b7c0198 --- /dev/null +++ b/TYPEORM_DEPENDENCIES.md @@ -0,0 +1,78 @@ +# Dependencias para TypeORM + Redis + +## Instrucciones de instalación + +Ejecutar los siguientes comandos para agregar las dependencias necesarias: + +```bash +cd /home/adrian/Documentos/workspace/projects/erp-suite/apps/erp-core/backend + +# Dependencias de producción +npm install typeorm reflect-metadata ioredis + +# Dependencias de desarrollo +npm install --save-dev @types/ioredis +``` + +## Detalle de dependencias + +### Producción (dependencies) + +1. **typeorm** (^0.3.x) + - ORM para TypeScript/JavaScript + - Permite trabajar con entities, repositories y query builders + - Soporta migraciones y subscribers + +2. **reflect-metadata** (^0.2.x) + - Requerido por TypeORM para decoradores + - Debe importarse al inicio de la aplicación + +3. **ioredis** (^5.x) + - Cliente Redis moderno para Node.js + - Usado para blacklist de tokens JWT + - Soporta clustering, pipelines y Lua scripts + +### Desarrollo (devDependencies) + +1. **@types/ioredis** (^5.x) + - Tipos TypeScript para ioredis + - Provee autocompletado e intellisense + +## Verificación post-instalación + +Después de instalar las dependencias, verificar que el proyecto compile: + +```bash +npm run build +``` + +Y que el servidor arranque correctamente: + +```bash +npm run dev +``` + +## Variables de entorno necesarias + +Agregar al archivo `.env`: + +```bash +# Redis (opcional - para blacklist de tokens) +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +``` + +## Archivos creados + +1. `/src/config/typeorm.ts` - Configuración de TypeORM DataSource +2. `/src/config/redis.ts` - Configuración de cliente Redis +3. `/src/index.ts` - Modificado para inicializar TypeORM y Redis + +## Próximos pasos + +1. Instalar las dependencias listadas arriba +2. Configurar variables de entorno de Redis en `.env` +3. Arrancar servidor con `npm run dev` y verificar logs +4. Comenzar a crear entities gradualmente en `src/modules/*/entities/` +5. Actualizar `typeorm.ts` para incluir las rutas de las entities creadas diff --git a/TYPEORM_INTEGRATION_SUMMARY.md b/TYPEORM_INTEGRATION_SUMMARY.md new file mode 100644 index 0000000..c25247f --- /dev/null +++ b/TYPEORM_INTEGRATION_SUMMARY.md @@ -0,0 +1,302 @@ +# Resumen de Integración TypeORM + Redis + +## Estado de la Tarea: COMPLETADO + +Integración exitosa de TypeORM y Redis al proyecto Express existente manteniendo total compatibilidad con el pool `pg` actual. + +--- + +## Archivos Creados + +### 1. `/src/config/typeorm.ts` +**Propósito:** Configuración del DataSource de TypeORM + +**Características:** +- DataSource configurado para PostgreSQL usando las mismas variables de entorno que el pool `pg` +- Schema por defecto: `auth` +- Logging habilitado en desarrollo, solo errores en producción +- Pool de conexiones reducido (max: 10) para no competir con el pool pg (max: 20) +- Synchronize deshabilitado (se usa DDL manual) +- Funciones exportadas: + - `AppDataSource` - DataSource principal + - `initializeTypeORM()` - Inicializa la conexión + - `closeTypeORM()` - Cierra la conexión + - `isTypeORMConnected()` - Verifica estado de conexión + +**Variables de entorno usadas:** +- `DB_HOST` +- `DB_PORT` +- `DB_USER` +- `DB_PASSWORD` +- `DB_NAME` + +### 2. `/src/config/redis.ts` +**Propósito:** Configuración de cliente Redis para blacklist de tokens JWT + +**Características:** +- Cliente ioredis con reconexión automática +- Logging completo de eventos (connect, ready, error, close, reconnecting) +- Conexión lazy (no automática) +- Redis es opcional - no detiene la aplicación si falla +- Utilidades para blacklist de tokens: + - `blacklistToken(token, expiresIn)` - Agrega token a blacklist + - `isTokenBlacklisted(token)` - Verifica si token está en blacklist + - `cleanupBlacklist()` - Limpieza manual (Redis maneja TTL automáticamente) + +**Funciones exportadas:** +- `redisClient` - Cliente Redis principal +- `initializeRedis()` - Inicializa conexión +- `closeRedis()` - Cierra conexión +- `isRedisConnected()` - Verifica estado +- `blacklistToken()` - Blacklist de token +- `isTokenBlacklisted()` - Verifica blacklist +- `cleanupBlacklist()` - Limpieza manual + +**Variables de entorno nuevas:** +- `REDIS_HOST` (default: localhost) +- `REDIS_PORT` (default: 6379) +- `REDIS_PASSWORD` (opcional) + +### 3. `/src/index.ts` (MODIFICADO) +**Cambios realizados:** + +1. **Importación de reflect-metadata** (línea 1-2): + ```typescript + import 'reflect-metadata'; + ``` + +2. **Importación de nuevos módulos** (líneas 7-8): + ```typescript + import { initializeTypeORM, closeTypeORM } from './config/typeorm.js'; + import { initializeRedis, closeRedis } from './config/redis.js'; + ``` + +3. **Inicialización en bootstrap()** (líneas 24-32): + ```typescript + // Initialize TypeORM DataSource + const typeormConnected = await initializeTypeORM(); + if (!typeormConnected) { + logger.error('Failed to initialize TypeORM. Exiting...'); + process.exit(1); + } + + // Initialize Redis (opcional - no detiene la app si falla) + await initializeRedis(); + ``` + +4. **Graceful shutdown actualizado** (líneas 48-51): + ```typescript + // Cerrar conexiones en orden + await closeRedis(); + await closeTypeORM(); + await closePool(); + ``` + +**Orden de inicialización:** +1. Pool pg (existente) - crítico +2. TypeORM DataSource - crítico +3. Redis - opcional +4. Express server + +**Orden de cierre:** +1. Express server +2. Redis +3. TypeORM +4. Pool pg + +--- + +## Dependencias a Instalar + +### Comando de instalación: +```bash +cd /home/adrian/Documentos/workspace/projects/erp-suite/apps/erp-core/backend + +# Producción +npm install typeorm reflect-metadata ioredis + +# Desarrollo +npm install --save-dev @types/ioredis +``` + +### Detalle: + +**Producción:** +- `typeorm` ^0.3.x - ORM principal +- `reflect-metadata` ^0.2.x - Requerido por decoradores de TypeORM +- `ioredis` ^5.x - Cliente Redis moderno + +**Desarrollo:** +- `@types/ioredis` ^5.x - Tipos TypeScript para ioredis + +--- + +## Variables de Entorno + +Agregar al archivo `.env`: + +```bash +# Redis Configuration (opcional) +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +# Las variables de PostgreSQL ya existen: +# DB_HOST=localhost +# DB_PORT=5432 +# DB_NAME=erp_generic +# DB_USER=erp_admin +# DB_PASSWORD=*** +``` + +--- + +## Compatibilidad con Pool `pg` Existente + +### Garantías de compatibilidad: + +1. **NO se modificó** `/src/config/database.ts` +2. **NO se eliminó** ninguna funcionalidad del pool pg +3. **Pool pg sigue siendo la conexión principal** para queries existentes +4. **TypeORM usa su propio pool** (max: 10 conexiones) independiente del pool pg (max: 20) +5. **Ambos pools coexisten** sin conflicto de recursos + +### Estrategia de migración gradual: + +``` +Código existente → Usa pool pg (database.ts) +Nuevo código → Puede usar TypeORM entities +No hay prisa → Migrar cuando sea conveniente +``` + +--- + +## Estructura de Directorios + +``` +backend/ +├── src/ +│ ├── config/ +│ │ ├── database.ts (EXISTENTE - pool pg) +│ │ ├── typeorm.ts (NUEVO - TypeORM DataSource) +│ │ ├── redis.ts (NUEVO - Redis client) +│ │ └── index.ts (EXISTENTE - sin cambios) +│ ├── index.ts (MODIFICADO - inicialización) +│ └── ... +├── TYPEORM_DEPENDENCIES.md (NUEVO - guía de instalación) +└── TYPEORM_INTEGRATION_SUMMARY.md (ESTE ARCHIVO) +``` + +--- + +## Próximos Pasos + +### 1. Instalar dependencias +```bash +npm install typeorm reflect-metadata ioredis +npm install --save-dev @types/ioredis +``` + +### 2. Configurar Redis (opcional) +Agregar variables `REDIS_*` al `.env` + +### 3. Verificar compilación +```bash +npm run build +``` + +### 4. Arrancar servidor +```bash +npm run dev +``` + +### 5. Verificar logs +Buscar en la consola: +- "Database connection successful" (pool pg) +- "TypeORM DataSource initialized successfully" (TypeORM) +- "Redis connection successful" o "Application will continue without Redis" (Redis) +- "Server running on port 3000" + +### 6. Crear entities (cuando sea necesario) +``` +src/modules/auth/entities/ +├── user.entity.ts +├── role.entity.ts +└── permission.entity.ts +``` + +### 7. Actualizar typeorm.ts +Agregar rutas de entities al array `entities` en AppDataSource: +```typescript +entities: [ + 'src/modules/auth/entities/*.entity.ts' +], +``` + +--- + +## Testing + +### Test de conexión TypeORM +```typescript +import { AppDataSource } from './config/typeorm.js'; + +// Verificar que esté inicializado +console.log(AppDataSource.isInitialized); // true +``` + +### Test de conexión Redis +```typescript +import { isRedisConnected, blacklistToken, isTokenBlacklisted } from './config/redis.js'; + +// Verificar conexión +console.log(isRedisConnected()); // true + +// Test de blacklist +await blacklistToken('test-token', 3600); +const isBlacklisted = await isTokenBlacklisted('test-token'); // true +``` + +--- + +## Criterios de Aceptación + +- [x] Archivo `src/config/typeorm.ts` creado +- [x] Archivo `src/config/redis.ts` creado +- [x] `src/index.ts` modificado para inicializar TypeORM +- [x] Compatibilidad con pool pg existente mantenida +- [x] reflect-metadata importado al inicio +- [x] Graceful shutdown actualizado +- [x] Documentación de dependencias creada +- [x] Variables de entorno documentadas + +--- + +## Notas Importantes + +1. **Redis es opcional** - Si Redis no está disponible, la aplicación arrancará normalmente pero la blacklist de tokens estará deshabilitada. + +2. **TypeORM es crítico** - Si TypeORM no puede inicializar, la aplicación no arrancará. Esto es intencional para detectar problemas temprano. + +3. **No usar synchronize** - Las tablas se crean manualmente con DDL, TypeORM solo las usa para queries. + +4. **Schema 'auth'** - TypeORM está configurado para usar el schema 'auth' por defecto. Asegurarse de que las entities se creen en este schema. + +5. **Logging** - En desarrollo, TypeORM logueará todas las queries. En producción, solo errores. + +--- + +## Soporte + +Si hay problemas durante la instalación o arranque: + +1. Verificar que todas las variables de entorno estén configuradas +2. Verificar que PostgreSQL esté corriendo y accesible +3. Verificar que Redis esté corriendo (opcional) +4. Revisar logs para mensajes de error específicos +5. Verificar que las dependencias se instalaron correctamente con `npm list typeorm reflect-metadata ioredis` + +--- + +**Fecha de creación:** 2025-12-12 +**Estado:** Listo para instalar dependencias y arrancar diff --git a/TYPEORM_USAGE_EXAMPLES.md b/TYPEORM_USAGE_EXAMPLES.md new file mode 100644 index 0000000..81d774c --- /dev/null +++ b/TYPEORM_USAGE_EXAMPLES.md @@ -0,0 +1,536 @@ +# Ejemplos de Uso de TypeORM + +Guía rápida para comenzar a usar TypeORM en el proyecto. + +--- + +## 1. Crear una Entity + +### Ejemplo: User Entity + +**Archivo:** `src/modules/auth/entities/user.entity.ts` + +```typescript +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToMany, + JoinTable, +} from 'typeorm'; +import { Role } from './role.entity'; + +@Entity('users', { schema: 'auth' }) +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true, length: 255 }) + email: string; + + @Column({ length: 255 }) + password: string; + + @Column({ name: 'first_name', length: 100 }) + firstName: string; + + @Column({ name: 'last_name', length: 100 }) + lastName: string; + + @Column({ default: true }) + active: boolean; + + @Column({ name: 'email_verified', default: false }) + emailVerified: boolean; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @ManyToMany(() => Role, role => role.users) + @JoinTable({ + name: 'user_roles', + joinColumn: { name: 'user_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' }, + }) + roles: Role[]; +} +``` + +### Ejemplo: Role Entity + +**Archivo:** `src/modules/auth/entities/role.entity.ts` + +```typescript +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToMany, +} from 'typeorm'; +import { User } from './user.entity'; + +@Entity('roles', { schema: 'auth' }) +export class Role { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true, length: 50 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @ManyToMany(() => User, user => user.roles) + users: User[]; +} +``` + +--- + +## 2. Actualizar typeorm.ts + +Después de crear entities, actualizar el array `entities` en `src/config/typeorm.ts`: + +```typescript +export const AppDataSource = new DataSource({ + // ... otras configuraciones ... + + entities: [ + 'src/modules/auth/entities/*.entity.ts', + // Agregar más rutas según sea necesario + ], + + // ... resto de configuración ... +}); +``` + +--- + +## 3. Usar Repository en un Service + +### Ejemplo: UserService + +**Archivo:** `src/modules/auth/services/user.service.ts` + +```typescript +import { Repository } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm.js'; +import { User } from '../entities/user.entity.js'; +import { Role } from '../entities/role.entity.js'; + +export class UserService { + private userRepository: Repository; + private roleRepository: Repository; + + constructor() { + this.userRepository = AppDataSource.getRepository(User); + this.roleRepository = AppDataSource.getRepository(Role); + } + + // Crear usuario + async createUser(data: { + email: string; + password: string; + firstName: string; + lastName: string; + }): Promise { + const user = this.userRepository.create(data); + return await this.userRepository.save(user); + } + + // Buscar usuario por email (con roles) + async findByEmail(email: string): Promise { + return await this.userRepository.findOne({ + where: { email }, + relations: ['roles'], + }); + } + + // Buscar usuario por ID + async findById(id: string): Promise { + return await this.userRepository.findOne({ + where: { id }, + relations: ['roles'], + }); + } + + // Listar todos los usuarios (con paginación) + async findAll(page: number = 1, limit: number = 10): Promise<{ + users: User[]; + total: number; + page: number; + totalPages: number; + }> { + const [users, total] = await this.userRepository.findAndCount({ + skip: (page - 1) * limit, + take: limit, + relations: ['roles'], + order: { createdAt: 'DESC' }, + }); + + return { + users, + total, + page, + totalPages: Math.ceil(total / limit), + }; + } + + // Actualizar usuario + async updateUser(id: string, data: Partial): Promise { + await this.userRepository.update(id, data); + return await this.findById(id); + } + + // Asignar rol a usuario + async assignRole(userId: string, roleId: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['roles'], + }); + + if (!user) return null; + + const role = await this.roleRepository.findOne({ + where: { id: roleId }, + }); + + if (!role) return null; + + if (!user.roles) user.roles = []; + user.roles.push(role); + + return await this.userRepository.save(user); + } + + // Eliminar usuario (soft delete) + async deleteUser(id: string): Promise { + const result = await this.userRepository.update(id, { active: false }); + return result.affected ? result.affected > 0 : false; + } +} +``` + +--- + +## 4. Query Builder (para queries complejas) + +### Ejemplo: Búsqueda avanzada de usuarios + +```typescript +async searchUsers(filters: { + search?: string; + active?: boolean; + roleId?: string; +}): Promise { + const query = this.userRepository + .createQueryBuilder('user') + .leftJoinAndSelect('user.roles', 'role'); + + if (filters.search) { + query.where( + 'user.email ILIKE :search OR user.firstName ILIKE :search OR user.lastName ILIKE :search', + { search: `%${filters.search}%` } + ); + } + + if (filters.active !== undefined) { + query.andWhere('user.active = :active', { active: filters.active }); + } + + if (filters.roleId) { + query.andWhere('role.id = :roleId', { roleId: filters.roleId }); + } + + return await query.getMany(); +} +``` + +--- + +## 5. Transacciones + +### Ejemplo: Crear usuario con roles en una transacción + +```typescript +async createUserWithRoles( + userData: { + email: string; + password: string; + firstName: string; + lastName: string; + }, + roleIds: string[] +): Promise { + return await AppDataSource.transaction(async (transactionalEntityManager) => { + // Crear usuario + const user = transactionalEntityManager.create(User, userData); + const savedUser = await transactionalEntityManager.save(user); + + // Buscar roles + const roles = await transactionalEntityManager.findByIds(Role, roleIds); + + // Asignar roles + savedUser.roles = roles; + return await transactionalEntityManager.save(savedUser); + }); +} +``` + +--- + +## 6. Raw Queries (cuando sea necesario) + +### Ejemplo: Query personalizada con parámetros + +```typescript +async getUserStats(): Promise<{ total: number; active: number; inactive: number }> { + const result = await AppDataSource.query( + ` + SELECT + COUNT(*) as total, + SUM(CASE WHEN active = true THEN 1 ELSE 0 END) as active, + SUM(CASE WHEN active = false THEN 1 ELSE 0 END) as inactive + FROM auth.users + ` + ); + + return result[0]; +} +``` + +--- + +## 7. Migrar código existente gradualmente + +### Antes (usando pool pg): + +```typescript +// src/modules/auth/services/user.service.ts (viejo) +import { query, queryOne } from '../../../config/database.js'; + +async findByEmail(email: string): Promise { + return await queryOne( + 'SELECT * FROM auth.users WHERE email = $1', + [email] + ); +} +``` + +### Después (usando TypeORM): + +```typescript +// src/modules/auth/services/user.service.ts (nuevo) +import { AppDataSource } from '../../../config/typeorm.js'; +import { User } from '../entities/user.entity.js'; + +async findByEmail(email: string): Promise { + const userRepository = AppDataSource.getRepository(User); + return await userRepository.findOne({ where: { email } }); +} +``` + +**Nota:** Ambos métodos pueden coexistir. Migrar gradualmente cuando sea conveniente. + +--- + +## 8. Uso en Controllers + +### Ejemplo: UserController + +**Archivo:** `src/modules/auth/controllers/user.controller.ts` + +```typescript +import { Request, Response } from 'express'; +import { UserService } from '../services/user.service.js'; + +export class UserController { + private userService: UserService; + + constructor() { + this.userService = new UserService(); + } + + // GET /api/v1/users + async getAll(req: Request, res: Response): Promise { + try { + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 10; + + const result = await this.userService.findAll(page, limit); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: 'Error fetching users', + }); + } + } + + // GET /api/v1/users/:id + async getById(req: Request, res: Response): Promise { + try { + const user = await this.userService.findById(req.params.id); + + if (!user) { + res.status(404).json({ + success: false, + error: 'User not found', + }); + return; + } + + res.json({ + success: true, + data: user, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: 'Error fetching user', + }); + } + } + + // POST /api/v1/users + async create(req: Request, res: Response): Promise { + try { + const user = await this.userService.createUser(req.body); + + res.status(201).json({ + success: true, + data: user, + }); + } catch (error) { + res.status(500).json({ + success: false, + error: 'Error creating user', + }); + } + } +} +``` + +--- + +## 9. Validación con Zod (integración) + +```typescript +import { z } from 'zod'; + +const createUserSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + firstName: z.string().min(2), + lastName: z.string().min(2), +}); + +async create(req: Request, res: Response): Promise { + try { + // Validar datos + const validatedData = createUserSchema.parse(req.body); + + // Crear usuario + const user = await this.userService.createUser(validatedData); + + res.status(201).json({ + success: true, + data: user, + }); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ + success: false, + error: 'Validation error', + details: error.errors, + }); + return; + } + + res.status(500).json({ + success: false, + error: 'Error creating user', + }); + } +} +``` + +--- + +## 10. Custom Repository (avanzado) + +### Ejemplo: UserRepository personalizado + +**Archivo:** `src/modules/auth/repositories/user.repository.ts` + +```typescript +import { Repository } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm.js'; +import { User } from '../entities/user.entity.js'; + +export class UserRepository extends Repository { + constructor() { + super(User, AppDataSource.createEntityManager()); + } + + // Método personalizado + async findActiveUsers(): Promise { + return this.createQueryBuilder('user') + .where('user.active = :active', { active: true }) + .andWhere('user.emailVerified = :verified', { verified: true }) + .leftJoinAndSelect('user.roles', 'role') + .orderBy('user.createdAt', 'DESC') + .getMany(); + } + + // Otro método personalizado + async findByRoleName(roleName: string): Promise { + return this.createQueryBuilder('user') + .leftJoinAndSelect('user.roles', 'role') + .where('role.name = :roleName', { roleName }) + .getMany(); + } +} +``` + +--- + +## Recursos Adicionales + +- [TypeORM Documentation](https://typeorm.io/) +- [TypeORM Entity Documentation](https://typeorm.io/entities) +- [TypeORM Relations](https://typeorm.io/relations) +- [TypeORM Query Builder](https://typeorm.io/select-query-builder) +- [TypeORM Migrations](https://typeorm.io/migrations) + +--- + +## Recomendaciones + +1. Comenzar con entities simples y agregar complejidad gradualmente +2. Usar Repository para queries simples +3. Usar QueryBuilder para queries complejas +4. Usar transacciones para operaciones que afectan múltiples tablas +5. Validar datos con Zod antes de guardar en base de datos +6. No usar `synchronize: true` en producción +7. Crear índices manualmente en DDL para mejor performance +8. Usar eager/lazy loading según el caso de uso +9. Documentar entities con comentarios JSDoc +10. Mantener código existente con pool pg hasta estar listo para migrar diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5267452 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,8585 @@ +{ + "name": "@erp-generic/backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@erp-generic/backend", + "version": "0.1.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "helmet": "^7.1.0", + "ioredis": "^5.8.2", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "pg": "^8.11.3", + "reflect-metadata": "^0.2.2", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", + "typeorm": "^0.3.28", + "uuid": "^9.0.1", + "winston": "^3.11.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/compression": "^1.7.5", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/ioredis": "^4.28.10", + "@types/jest": "^29.5.11", + "@types/jsonwebtoken": "^9.0.5", + "@types/morgan": "^1.9.9", + "@types/node": "^20.10.4", + "@types/pg": "^8.10.9", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.8", + "@types/uuid": "^9.0.7", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "eslint": "^8.56.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "tsx": "^4.6.2", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", + "dependencies": { + "@so-ric/colorspace": "^1.1.6", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", + "integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz", + "integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz", + "integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz", + "integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz", + "integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz", + "integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz", + "integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz", + "integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz", + "integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz", + "integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz", + "integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz", + "integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz", + "integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz", + "integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz", + "integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz", + "integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz", + "integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz", + "integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz", + "integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz", + "integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz", + "integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz", + "integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz", + "integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz", + "integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz", + "integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz", + "integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "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.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "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/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "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/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "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", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@ioredis/commands": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", + "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "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/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "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/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "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/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "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", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" + } + }, + "node_modules/@sqltools/formatter": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", + "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.7", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", + "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ioredis": { + "version": "4.28.10", + "resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.28.10.tgz", + "integrity": "sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/morgan": { + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", + "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.26", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.26.tgz", + "integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.15.6", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", + "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/swagger-jsdoc": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", + "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "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-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/app-root-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz", + "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/baseline-browser-mapping": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.5.tgz", + "integrity": "sha512-D5vIoztZOq1XM54LUdttJVc96ggEsIfju2JBvht06pSzpckp3C7HReun67Bghzrtdsq9XdMGbSSB3v3GhMNmAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "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.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001759", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", + "integrity": "sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "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/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", + "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", + "license": "MIT", + "dependencies": { + "color-convert": "^3.1.3", + "color-string": "^2.1.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", + "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/color-string/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", + "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", + "license": "MIT", + "dependencies": { + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz", + "integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", + "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.1", + "@esbuild/android-arm": "0.27.1", + "@esbuild/android-arm64": "0.27.1", + "@esbuild/android-x64": "0.27.1", + "@esbuild/darwin-arm64": "0.27.1", + "@esbuild/darwin-x64": "0.27.1", + "@esbuild/freebsd-arm64": "0.27.1", + "@esbuild/freebsd-x64": "0.27.1", + "@esbuild/linux-arm": "0.27.1", + "@esbuild/linux-arm64": "0.27.1", + "@esbuild/linux-ia32": "0.27.1", + "@esbuild/linux-loong64": "0.27.1", + "@esbuild/linux-mips64el": "0.27.1", + "@esbuild/linux-ppc64": "0.27.1", + "@esbuild/linux-riscv64": "0.27.1", + "@esbuild/linux-s390x": "0.27.1", + "@esbuild/linux-x64": "0.27.1", + "@esbuild/netbsd-arm64": "0.27.1", + "@esbuild/netbsd-x64": "0.27.1", + "@esbuild/openbsd-arm64": "0.27.1", + "@esbuild/openbsd-x64": "0.27.1", + "@esbuild/openharmony-arm64": "0.27.1", + "@esbuild/sunos-x64": "0.27.1", + "@esbuild/win32-arm64": "0.27.1", + "@esbuild/win32-ia32": "0.27.1", + "@esbuild/win32-x64": "0.27.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "peer": true, + "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", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "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", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "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/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "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", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "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/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "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", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "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/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz", + "integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@ioredis/commands": "1.4.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "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/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "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", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "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-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "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", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "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/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "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/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "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", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.1.tgz", + "integrity": "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-static/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static/node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/sql-highlight": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.1.0.tgz", + "integrity": "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==", + "funding": [ + "https://github.com/scriptcoded/sql-highlight?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/scriptcoded" + } + ], + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "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/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "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/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-jsdoc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", + "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-jest": { + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typeorm": { + "version": "0.3.28", + "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", + "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", + "license": "MIT", + "dependencies": { + "@sqltools/formatter": "^1.2.5", + "ansis": "^4.2.0", + "app-root-path": "^3.1.0", + "buffer": "^6.0.3", + "dayjs": "^1.11.19", + "debug": "^4.4.3", + "dedent": "^1.7.0", + "dotenv": "^16.6.1", + "glob": "^10.5.0", + "reflect-metadata": "^0.2.2", + "sha.js": "^2.4.12", + "sql-highlight": "^6.1.0", + "tslib": "^2.8.1", + "uuid": "^11.1.0", + "yargs": "^17.7.2" + }, + "bin": { + "typeorm": "cli.js", + "typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js", + "typeorm-ts-node-esm": "cli-ts-node-esm.js" + }, + "engines": { + "node": ">=16.13.0" + }, + "funding": { + "url": "https://opencollective.com/typeorm" + }, + "peerDependencies": { + "@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@sap/hana-client": "^2.14.22", + "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "ioredis": "^5.0.4", + "mongodb": "^5.8.0 || ^6.0.0", + "mssql": "^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "mysql2": "^2.2.5 || ^3.0.1", + "oracledb": "^6.3.0", + "pg": "^8.5.1", + "pg-native": "^3.0.0", + "pg-query-stream": "^4.0.0", + "redis": "^3.1.1 || ^4.0.0 || ^5.0.14", + "sql.js": "^1.4.0", + "sqlite3": "^5.0.3", + "ts-node": "^10.7.0", + "typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@google-cloud/spanner": { + "optional": true + }, + "@sap/hana-client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mssql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "redis": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "ts-node": { + "optional": true + }, + "typeorm-aurora-data-api-driver": { + "optional": true + } + } + }, + "node_modules/typeorm/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "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/typeorm/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", + "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/validator": { + "version": "13.15.23", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", + "integrity": "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/winston": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.8", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "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/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "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/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..427afb7 --- /dev/null +++ b/package.json @@ -0,0 +1,59 @@ +{ + "name": "@erp-generic/backend", + "version": "0.1.0", + "description": "ERP Generic Backend API", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "eslint src --ext .ts", + "test": "jest", + "test:watch": "jest --watch", + "test:coverage": "jest --coverage" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "helmet": "^7.1.0", + "ioredis": "^5.8.2", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "pg": "^8.11.3", + "reflect-metadata": "^0.2.2", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", + "typeorm": "^0.3.28", + "uuid": "^9.0.1", + "winston": "^3.11.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/compression": "^1.7.5", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/ioredis": "^4.28.10", + "@types/jest": "^29.5.11", + "@types/jsonwebtoken": "^9.0.5", + "@types/morgan": "^1.9.9", + "@types/node": "^20.10.4", + "@types/pg": "^8.10.9", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.8", + "@types/uuid": "^9.0.7", + "@typescript-eslint/eslint-plugin": "^6.14.0", + "@typescript-eslint/parser": "^6.14.0", + "eslint": "^8.56.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "tsx": "^4.6.2", + "typescript": "^5.3.3" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/service.descriptor.yml b/service.descriptor.yml new file mode 100644 index 0000000..3eada79 --- /dev/null +++ b/service.descriptor.yml @@ -0,0 +1,134 @@ +# ============================================================================== +# SERVICE DESCRIPTOR - ERP CORE API +# ============================================================================== +# API central del ERP Suite +# Mantenido por: Backend-Agent +# Actualizado: 2025-12-18 +# ============================================================================== + +version: "1.0.0" + +# ------------------------------------------------------------------------------ +# IDENTIFICACION DEL SERVICIO +# ------------------------------------------------------------------------------ +service: + name: "erp-core-api" + display_name: "ERP Core API" + description: "API central con funcionalidad compartida del ERP" + type: "backend" + runtime: "node" + framework: "nestjs" + owner_agent: "NEXUS-BACKEND" + +# ------------------------------------------------------------------------------ +# CONFIGURACION DE PUERTOS +# ------------------------------------------------------------------------------ +ports: + internal: 3010 + registry_ref: "projects.erp_suite.services.api" + protocol: "http" + +# ------------------------------------------------------------------------------ +# CONFIGURACION DE BASE DE DATOS +# ------------------------------------------------------------------------------ +database: + registry_ref: "erp_core" + schemas: + - "public" + - "auth" + - "core" + role: "runtime" + +# ------------------------------------------------------------------------------ +# DEPENDENCIAS +# ------------------------------------------------------------------------------ +dependencies: + services: + - name: "postgres" + type: "database" + required: true + - name: "redis" + type: "cache" + required: false + +# ------------------------------------------------------------------------------ +# MODULOS +# ------------------------------------------------------------------------------ +modules: + auth: + description: "Autenticacion y sesiones" + endpoints: + - { path: "/auth/login", method: "POST" } + - { path: "/auth/register", method: "POST" } + - { path: "/auth/refresh", method: "POST" } + - { path: "/auth/logout", method: "POST" } + + users: + description: "Gestion de usuarios" + endpoints: + - { path: "/users", method: "GET" } + - { path: "/users/:id", method: "GET" } + - { path: "/users", method: "POST" } + - { path: "/users/:id", method: "PUT" } + + companies: + description: "Gestion de empresas" + endpoints: + - { path: "/companies", method: "GET" } + - { path: "/companies/:id", method: "GET" } + - { path: "/companies", method: "POST" } + + tenants: + description: "Multi-tenancy" + endpoints: + - { path: "/tenants", method: "GET" } + - { path: "/tenants/:id", method: "GET" } + + core: + description: "Catalogos base" + submodules: + - countries + - currencies + - uom + - sequences + +# ------------------------------------------------------------------------------ +# DOCKER +# ------------------------------------------------------------------------------ +docker: + image: "erp-core-api" + dockerfile: "Dockerfile" + networks: + - "erp_core_${ENV:-local}" + - "infra_shared" + labels: + traefik: + enable: true + router: "erp-core-api" + rule: "Host(`api.erp.localhost`)" + +# ------------------------------------------------------------------------------ +# HEALTH CHECK +# ------------------------------------------------------------------------------ +healthcheck: + endpoint: "/health" + interval: "30s" + timeout: "5s" + retries: 3 + +# ------------------------------------------------------------------------------ +# ESTADO +# ------------------------------------------------------------------------------ +status: + phase: "development" + version: "0.1.0" + completeness: 25 + +# ------------------------------------------------------------------------------ +# METADATA +# ------------------------------------------------------------------------------ +metadata: + created_at: "2025-12-18" + created_by: "Backend-Agent" + project: "erp-suite" + team: "erp-team" diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..d98076d --- /dev/null +++ b/src/app.ts @@ -0,0 +1,112 @@ +import express, { Application, Request, Response, NextFunction } from 'express'; +import cors from 'cors'; +import helmet from 'helmet'; +import compression from 'compression'; +import morgan from 'morgan'; +import { config } from './config/index.js'; +import { logger } from './shared/utils/logger.js'; +import { AppError, ApiResponse } from './shared/types/index.js'; +import { setupSwagger } from './config/swagger.config.js'; +import authRoutes from './modules/auth/auth.routes.js'; +import apiKeysRoutes from './modules/auth/apiKeys.routes.js'; +import usersRoutes from './modules/users/users.routes.js'; +import { rolesRoutes, permissionsRoutes } from './modules/roles/index.js'; +import { tenantsRoutes } from './modules/tenants/index.js'; +import companiesRoutes from './modules/companies/companies.routes.js'; +import coreRoutes from './modules/core/core.routes.js'; +import partnersRoutes from './modules/partners/partners.routes.js'; +import inventoryRoutes from './modules/inventory/inventory.routes.js'; +import financialRoutes from './modules/financial/financial.routes.js'; +import purchasesRoutes from './modules/purchases/purchases.routes.js'; +import salesRoutes from './modules/sales/sales.routes.js'; +import projectsRoutes from './modules/projects/projects.routes.js'; +import systemRoutes from './modules/system/system.routes.js'; +import crmRoutes from './modules/crm/crm.routes.js'; +import hrRoutes from './modules/hr/hr.routes.js'; +import reportsRoutes from './modules/reports/reports.routes.js'; + +const app: Application = express(); + +// Security middleware +app.use(helmet()); +app.use(cors({ + origin: config.cors.origin, + credentials: true, +})); + +// Request parsing +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true })); +app.use(compression()); + +// Logging +const morganFormat = config.env === 'production' ? 'combined' : 'dev'; +app.use(morgan(morganFormat, { + stream: { write: (message) => logger.http(message.trim()) } +})); + +// Swagger documentation +const apiPrefix = config.apiPrefix; +setupSwagger(app, apiPrefix); + +// Health check +app.get('/health', (_req: Request, res: Response) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }); +}); + +// API routes +app.use(`${apiPrefix}/auth`, authRoutes); +app.use(`${apiPrefix}/auth/api-keys`, apiKeysRoutes); +app.use(`${apiPrefix}/users`, usersRoutes); +app.use(`${apiPrefix}/roles`, rolesRoutes); +app.use(`${apiPrefix}/permissions`, permissionsRoutes); +app.use(`${apiPrefix}/tenants`, tenantsRoutes); +app.use(`${apiPrefix}/companies`, companiesRoutes); +app.use(`${apiPrefix}/core`, coreRoutes); +app.use(`${apiPrefix}/partners`, partnersRoutes); +app.use(`${apiPrefix}/inventory`, inventoryRoutes); +app.use(`${apiPrefix}/financial`, financialRoutes); +app.use(`${apiPrefix}/purchases`, purchasesRoutes); +app.use(`${apiPrefix}/sales`, salesRoutes); +app.use(`${apiPrefix}/projects`, projectsRoutes); +app.use(`${apiPrefix}/system`, systemRoutes); +app.use(`${apiPrefix}/crm`, crmRoutes); +app.use(`${apiPrefix}/hr`, hrRoutes); +app.use(`${apiPrefix}/reports`, reportsRoutes); + +// 404 handler +app.use((_req: Request, res: Response) => { + const response: ApiResponse = { + success: false, + error: 'Endpoint no encontrado' + }; + res.status(404).json(response); +}); + +// Global error handler +app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { + logger.error('Unhandled error', { + error: err.message, + stack: err.stack, + name: err.name + }); + + if (err instanceof AppError) { + const response: ApiResponse = { + success: false, + error: err.message, + }; + return res.status(err.statusCode).json(response); + } + + // Generic error + const response: ApiResponse = { + success: false, + error: config.env === 'production' + ? 'Error interno del servidor' + : err.message, + }; + res.status(500).json(response); +}); + +export default app; diff --git a/src/config/database.ts b/src/config/database.ts new file mode 100644 index 0000000..7df470d --- /dev/null +++ b/src/config/database.ts @@ -0,0 +1,69 @@ +import { Pool, PoolConfig, PoolClient } from 'pg'; + +// Re-export PoolClient for use in services +export type { PoolClient }; +import { config } from './index.js'; +import { logger } from '../shared/utils/logger.js'; + +const poolConfig: PoolConfig = { + host: config.database.host, + port: config.database.port, + database: config.database.name, + user: config.database.user, + password: config.database.password, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, +}; + +export const pool = new Pool(poolConfig); + +pool.on('connect', () => { + logger.debug('New database connection established'); +}); + +pool.on('error', (err) => { + logger.error('Unexpected database error', { error: err.message }); +}); + +export async function testConnection(): Promise { + try { + const client = await pool.connect(); + const result = await client.query('SELECT NOW()'); + client.release(); + logger.info('Database connection successful', { timestamp: result.rows[0].now }); + return true; + } catch (error) { + logger.error('Database connection failed', { error: (error as Error).message }); + return false; + } +} + +export async function query(text: string, params?: any[]): Promise { + const start = Date.now(); + const result = await pool.query(text, params); + const duration = Date.now() - start; + + logger.debug('Query executed', { + text: text.substring(0, 100), + duration: `${duration}ms`, + rows: result.rowCount + }); + + return result.rows as T[]; +} + +export async function queryOne(text: string, params?: any[]): Promise { + const rows = await query(text, params); + return rows[0] || null; +} + +export async function getClient() { + const client = await pool.connect(); + return client; +} + +export async function closePool(): Promise { + await pool.end(); + logger.info('Database pool closed'); +} diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..e612525 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,35 @@ +import dotenv from 'dotenv'; +import path from 'path'; + +// Load .env file +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); + +export const config = { + env: process.env.NODE_ENV || 'development', + port: parseInt(process.env.PORT || '3000', 10), + apiPrefix: process.env.API_PREFIX || '/api/v1', + + database: { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + name: process.env.DB_NAME || 'erp_generic', + user: process.env.DB_USER || 'erp_admin', + password: process.env.DB_PASSWORD || '', + }, + + jwt: { + secret: process.env.JWT_SECRET || 'change-this-secret', + expiresIn: process.env.JWT_EXPIRES_IN || '24h', + refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d', + }, + + logging: { + level: process.env.LOG_LEVEL || 'info', + }, + + cors: { + origin: process.env.CORS_ORIGIN || 'http://localhost:5173', + }, +} as const; + +export type Config = typeof config; diff --git a/src/config/redis.ts b/src/config/redis.ts new file mode 100644 index 0000000..445050c --- /dev/null +++ b/src/config/redis.ts @@ -0,0 +1,178 @@ +import Redis from 'ioredis'; +import { logger } from '../shared/utils/logger.js'; + +/** + * Configuración de Redis para blacklist de tokens JWT + */ +const redisConfig = { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + password: process.env.REDIS_PASSWORD || undefined, + + // Configuración de reconexión + retryStrategy(times: number) { + const delay = Math.min(times * 50, 2000); + return delay; + }, + + // Timeouts + connectTimeout: 10000, + maxRetriesPerRequest: 3, + + // Logging de eventos + lazyConnect: true, // No conectar automáticamente, esperar a connect() +}; + +/** + * Cliente Redis para blacklist de tokens + */ +export const redisClient = new Redis(redisConfig); + +// Event listeners +redisClient.on('connect', () => { + logger.info('Redis client connecting...', { + host: redisConfig.host, + port: redisConfig.port, + }); +}); + +redisClient.on('ready', () => { + logger.info('Redis client ready'); +}); + +redisClient.on('error', (error) => { + logger.error('Redis client error', { + error: error.message, + stack: error.stack, + }); +}); + +redisClient.on('close', () => { + logger.warn('Redis connection closed'); +}); + +redisClient.on('reconnecting', () => { + logger.info('Redis client reconnecting...'); +}); + +/** + * Inicializa la conexión a Redis + * @returns Promise - true si la conexión fue exitosa + */ +export async function initializeRedis(): Promise { + try { + await redisClient.connect(); + + // Test de conexión + await redisClient.ping(); + + logger.info('Redis connection successful', { + host: redisConfig.host, + port: redisConfig.port, + }); + + return true; + } catch (error) { + logger.error('Failed to connect to Redis', { + error: (error as Error).message, + host: redisConfig.host, + port: redisConfig.port, + }); + + // Redis es opcional, no debe detener la app + logger.warn('Application will continue without Redis (token blacklist disabled)'); + return false; + } +} + +/** + * Cierra la conexión a Redis + */ +export async function closeRedis(): Promise { + try { + await redisClient.quit(); + logger.info('Redis connection closed gracefully'); + } catch (error) { + logger.error('Error closing Redis connection', { + error: (error as Error).message, + }); + + // Forzar desconexión si quit() falla + redisClient.disconnect(); + } +} + +/** + * Verifica si Redis está conectado + */ +export function isRedisConnected(): boolean { + return redisClient.status === 'ready'; +} + +// ===== Utilidades para Token Blacklist ===== + +/** + * Agrega un token a la blacklist + * @param token - Token JWT a invalidar + * @param expiresIn - Tiempo de expiración en segundos + */ +export async function blacklistToken(token: string, expiresIn: number): Promise { + if (!isRedisConnected()) { + logger.warn('Cannot blacklist token: Redis not connected'); + return; + } + + try { + const key = `blacklist:${token}`; + await redisClient.setex(key, expiresIn, '1'); + logger.debug('Token added to blacklist', { expiresIn }); + } catch (error) { + logger.error('Error blacklisting token', { + error: (error as Error).message, + }); + } +} + +/** + * Verifica si un token está en la blacklist + * @param token - Token JWT a verificar + * @returns Promise - true si el token está en blacklist + */ +export async function isTokenBlacklisted(token: string): Promise { + if (!isRedisConnected()) { + logger.warn('Cannot check blacklist: Redis not connected'); + return false; // Si Redis no está disponible, permitir el acceso + } + + try { + const key = `blacklist:${token}`; + const result = await redisClient.get(key); + return result !== null; + } catch (error) { + logger.error('Error checking token blacklist', { + error: (error as Error).message, + }); + return false; // En caso de error, no bloquear el acceso + } +} + +/** + * Limpia tokens expirados de la blacklist + * Nota: Redis hace esto automáticamente con SETEX, esta función es para uso manual si es necesario + */ +export async function cleanupBlacklist(): Promise { + if (!isRedisConnected()) { + logger.warn('Cannot cleanup blacklist: Redis not connected'); + return; + } + + try { + // Redis maneja automáticamente la expiración con SETEX + // Esta función está disponible para limpieza manual si se necesita + logger.info('Blacklist cleanup completed (handled by Redis TTL)'); + } catch (error) { + logger.error('Error during blacklist cleanup', { + error: (error as Error).message, + }); + } +} diff --git a/src/config/swagger.config.ts b/src/config/swagger.config.ts new file mode 100644 index 0000000..0623bb6 --- /dev/null +++ b/src/config/swagger.config.ts @@ -0,0 +1,200 @@ +/** + * Swagger/OpenAPI Configuration for ERP Generic Core + */ + +import swaggerJSDoc from 'swagger-jsdoc'; +import { Express } from 'express'; +import swaggerUi from 'swagger-ui-express'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Swagger definition +const swaggerDefinition = { + openapi: '3.0.0', + info: { + title: 'ERP Generic - Core API', + version: '0.1.0', + description: ` + API para el sistema ERP genérico multitenant. + + ## Características principales + - Autenticación JWT y gestión de sesiones + - Multi-tenant con aislamiento de datos por empresa + - Gestión financiera y contable completa + - Control de inventario y almacenes + - Módulos de compras y ventas + - CRM y gestión de partners (clientes, proveedores) + - Proyectos y recursos humanos + - Sistema de permisos granular mediante API Keys + + ## Autenticación + Todos los endpoints requieren autenticación mediante Bearer Token (JWT). + El token debe incluirse en el header Authorization: Bearer + + ## Multi-tenant + El sistema identifica automáticamente la empresa (tenant) del usuario autenticado + y filtra todos los datos según el contexto de la empresa. + `, + contact: { + name: 'ERP Generic Support', + email: 'support@erpgeneric.com', + }, + license: { + name: 'Proprietary', + }, + }, + servers: [ + { + url: 'http://localhost:3003/api/v1', + description: 'Desarrollo local', + }, + { + url: 'https://api.erpgeneric.com/api/v1', + description: 'Producción', + }, + ], + tags: [ + { name: 'Auth', description: 'Autenticación y autorización (JWT)' }, + { name: 'Users', description: 'Gestión de usuarios y perfiles' }, + { name: 'Companies', description: 'Gestión de empresas (multi-tenant)' }, + { name: 'Core', description: 'Configuración central y parámetros del sistema' }, + { name: 'Partners', description: 'Gestión de partners (clientes, proveedores, contactos)' }, + { name: 'Inventory', description: 'Control de inventario, productos y almacenes' }, + { name: 'Financial', description: 'Gestión financiera, contable y movimientos' }, + { name: 'Purchases', description: 'Módulo de compras y órdenes de compra' }, + { name: 'Sales', description: 'Módulo de ventas, cotizaciones y pedidos' }, + { name: 'Projects', description: 'Gestión de proyectos y tareas' }, + { name: 'System', description: 'Configuración del sistema, logs y auditoría' }, + { name: 'CRM', description: 'CRM, oportunidades y seguimiento comercial' }, + { name: 'HR', description: 'Recursos humanos, empleados y nómina' }, + { name: 'Reports', description: 'Reportes y analíticas del sistema' }, + { name: 'Health', description: 'Health checks y monitoreo' }, + ], + components: { + securitySchemes: { + BearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Token JWT obtenido del endpoint de login', + }, + ApiKeyAuth: { + type: 'apiKey', + in: 'header', + name: 'X-API-Key', + description: 'API Key para operaciones administrativas específicas', + }, + }, + schemas: { + ApiResponse: { + type: 'object', + properties: { + success: { + type: 'boolean', + }, + data: { + type: 'object', + }, + error: { + type: 'string', + }, + }, + }, + PaginatedResponse: { + type: 'object', + properties: { + success: { + type: 'boolean', + example: true, + }, + data: { + type: 'array', + items: { + type: 'object', + }, + }, + pagination: { + type: 'object', + properties: { + page: { + type: 'integer', + example: 1, + }, + limit: { + type: 'integer', + example: 20, + }, + total: { + type: 'integer', + example: 100, + }, + totalPages: { + type: 'integer', + example: 5, + }, + }, + }, + }, + }, + }, + }, + security: [ + { + BearerAuth: [], + }, + ], +}; + +// Options for swagger-jsdoc +const options: swaggerJSDoc.Options = { + definition: swaggerDefinition, + // Path to the API routes for JSDoc comments + apis: [ + path.join(__dirname, '../modules/**/*.routes.ts'), + path.join(__dirname, '../modules/**/*.routes.js'), + path.join(__dirname, '../docs/openapi.yaml'), + ], +}; + +// Initialize swagger-jsdoc +const swaggerSpec = swaggerJSDoc(options); + +/** + * Setup Swagger documentation for Express app + */ +export function setupSwagger(app: Express, prefix: string = '/api/v1') { + // Swagger UI options + const swaggerUiOptions = { + customCss: ` + .swagger-ui .topbar { display: none } + .swagger-ui .info { margin: 50px 0; } + .swagger-ui .info .title { font-size: 36px; } + `, + customSiteTitle: 'ERP Generic - API Documentation', + swaggerOptions: { + persistAuthorization: true, + displayRequestDuration: true, + filter: true, + tagsSorter: 'alpha', + operationsSorter: 'alpha', + }, + }; + + // Serve Swagger UI + app.use(`${prefix}/docs`, swaggerUi.serve); + app.get(`${prefix}/docs`, swaggerUi.setup(swaggerSpec, swaggerUiOptions)); + + // Serve OpenAPI spec as JSON + app.get(`${prefix}/docs.json`, (req, res) => { + res.setHeader('Content-Type', 'application/json'); + res.send(swaggerSpec); + }); + + console.log(`📚 Swagger docs available at: http://localhost:${process.env.PORT || 3003}${prefix}/docs`); + console.log(`📄 OpenAPI spec JSON at: http://localhost:${process.env.PORT || 3003}${prefix}/docs.json`); +} + +export { swaggerSpec }; diff --git a/src/config/typeorm.ts b/src/config/typeorm.ts new file mode 100644 index 0000000..2b50f26 --- /dev/null +++ b/src/config/typeorm.ts @@ -0,0 +1,215 @@ +import { DataSource } from 'typeorm'; +import { config } from './index.js'; +import { logger } from '../shared/utils/logger.js'; + +// Import Auth Core Entities +import { + Tenant, + Company, + User, + Role, + Permission, + Session, + PasswordReset, +} from '../modules/auth/entities/index.js'; + +// Import Auth Extension Entities +import { + Group, + ApiKey, + TrustedDevice, + VerificationCode, + MfaAuditLog, + OAuthProvider, + OAuthUserLink, + OAuthState, +} from '../modules/auth/entities/index.js'; + +// Import Core Module Entities +import { Partner } from '../modules/partners/entities/index.js'; +import { + Currency, + Country, + UomCategory, + Uom, + ProductCategory, + Sequence, +} from '../modules/core/entities/index.js'; + +// Import Financial Entities +import { + AccountType, + Account, + Journal, + JournalEntry, + JournalEntryLine, + Invoice, + InvoiceLine, + Payment, + Tax, + FiscalYear, + FiscalPeriod, +} from '../modules/financial/entities/index.js'; + +// Import Inventory Entities +import { + Product, + Warehouse, + Location, + StockQuant, + Lot, + Picking, + StockMove, + InventoryAdjustment, + InventoryAdjustmentLine, + StockValuationLayer, +} from '../modules/inventory/entities/index.js'; + +/** + * TypeORM DataSource configuration + * + * Configurado para coexistir con el pool pg existente. + * Permite migración gradual a entities sin romper el código actual. + */ +export const AppDataSource = new DataSource({ + type: 'postgres', + host: config.database.host, + port: config.database.port, + username: config.database.user, + password: config.database.password, + database: config.database.name, + + // Schema por defecto para entities de autenticación + schema: 'auth', + + // Entities registradas + entities: [ + // Auth Core Entities + Tenant, + Company, + User, + Role, + Permission, + Session, + PasswordReset, + // Auth Extension Entities + Group, + ApiKey, + TrustedDevice, + VerificationCode, + MfaAuditLog, + OAuthProvider, + OAuthUserLink, + OAuthState, + // Core Module Entities + Partner, + Currency, + Country, + UomCategory, + Uom, + ProductCategory, + Sequence, + // Financial Entities + AccountType, + Account, + Journal, + JournalEntry, + JournalEntryLine, + Invoice, + InvoiceLine, + Payment, + Tax, + FiscalYear, + FiscalPeriod, + // Inventory Entities + Product, + Warehouse, + Location, + StockQuant, + Lot, + Picking, + StockMove, + InventoryAdjustment, + InventoryAdjustmentLine, + StockValuationLayer, + ], + + // Directorios de migraciones (para uso futuro) + migrations: [ + // 'src/database/migrations/*.ts' + ], + + // Directorios de subscribers (para uso futuro) + subscribers: [ + // 'src/database/subscribers/*.ts' + ], + + // NO usar synchronize en producción - usamos DDL manual + synchronize: false, + + // Logging: habilitado en desarrollo, solo errores en producción + logging: config.env === 'development' ? ['query', 'error', 'warn'] : ['error'], + + // Log queries lentas (> 1000ms) + maxQueryExecutionTime: 1000, + + // Pool de conexiones (configuración conservadora para no interferir con pool pg) + extra: { + max: 10, // Menor que el pool pg (20) para no competir por conexiones + min: 2, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + }, + + // Cache de queries (opcional, se puede habilitar después) + cache: false, +}); + +/** + * Inicializa la conexión TypeORM + * @returns Promise - true si la conexión fue exitosa + */ +export async function initializeTypeORM(): Promise { + try { + if (!AppDataSource.isInitialized) { + await AppDataSource.initialize(); + logger.info('TypeORM DataSource initialized successfully', { + database: config.database.name, + schema: 'auth', + host: config.database.host, + }); + return true; + } + logger.warn('TypeORM DataSource already initialized'); + return true; + } catch (error) { + logger.error('Failed to initialize TypeORM DataSource', { + error: (error as Error).message, + stack: (error as Error).stack, + }); + return false; + } +} + +/** + * Cierra la conexión TypeORM + */ +export async function closeTypeORM(): Promise { + try { + if (AppDataSource.isInitialized) { + await AppDataSource.destroy(); + logger.info('TypeORM DataSource closed'); + } + } catch (error) { + logger.error('Error closing TypeORM DataSource', { + error: (error as Error).message, + }); + } +} + +/** + * Obtiene el estado de la conexión TypeORM + */ +export function isTypeORMConnected(): boolean { + return AppDataSource.isInitialized; +} diff --git a/src/docs/openapi.yaml b/src/docs/openapi.yaml new file mode 100644 index 0000000..2b616d2 --- /dev/null +++ b/src/docs/openapi.yaml @@ -0,0 +1,138 @@ +openapi: 3.0.0 +info: + title: ERP Generic - Core API + description: | + API para el sistema ERP genérico multitenant. + + ## Características principales + - Autenticación JWT y gestión de sesiones + - Multi-tenant con aislamiento de datos + - Gestión financiera y contable + - Control de inventario y almacenes + - Compras y ventas + - CRM y gestión de partners + - Proyectos y recursos humanos + - Sistema de permisos granular (API Keys) + + ## Autenticación + Todos los endpoints requieren autenticación mediante Bearer Token (JWT). + Algunos endpoints administrativos pueden requerir API Key específica. + + version: 0.1.0 + contact: + name: ERP Generic Support + email: support@erpgeneric.com + license: + name: Proprietary + +servers: + - url: http://localhost:3003/api/v1 + description: Desarrollo local + - url: https://api.erpgeneric.com/api/v1 + description: Producción + +tags: + - name: Auth + description: Autenticación y autorización + - name: Users + description: Gestión de usuarios + - name: Companies + description: Gestión de empresas (tenants) + - name: Core + description: Configuración central y parámetros + - name: Partners + description: Gestión de partners (clientes, proveedores, contactos) + - name: Inventory + description: Control de inventario y productos + - name: Financial + description: Gestión financiera y contable + - name: Purchases + description: Compras y órdenes de compra + - name: Sales + description: Ventas, cotizaciones y pedidos + - name: Projects + description: Gestión de proyectos y tareas + - name: System + description: Configuración del sistema y logs + - name: CRM + description: CRM y gestión de oportunidades + - name: HR + description: Recursos humanos y empleados + - name: Reports + description: Reportes y analíticas + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: Token JWT obtenido del endpoint de login + + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + description: API Key para operaciones específicas + + schemas: + ApiResponse: + type: object + properties: + success: + type: boolean + data: + type: object + error: + type: string + + PaginatedResponse: + type: object + properties: + success: + type: boolean + example: true + data: + type: array + items: + type: object + pagination: + type: object + properties: + page: + type: integer + example: 1 + limit: + type: integer + example: 20 + total: + type: integer + example: 100 + totalPages: + type: integer + example: 5 + +security: + - BearerAuth: [] + +paths: + /health: + get: + tags: + - Health + summary: Health check del servidor + security: [] + responses: + '200': + description: Servidor funcionando correctamente + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: ok + timestamp: + type: string + format: date-time diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9fed9f9 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,71 @@ +// Importar reflect-metadata al inicio (requerido por TypeORM) +import 'reflect-metadata'; + +import app from './app.js'; +import { config } from './config/index.js'; +import { testConnection, closePool } from './config/database.js'; +import { initializeTypeORM, closeTypeORM } from './config/typeorm.js'; +import { initializeRedis, closeRedis } from './config/redis.js'; +import { logger } from './shared/utils/logger.js'; + +async function bootstrap(): Promise { + logger.info('Starting ERP Generic Backend...', { + env: config.env, + port: config.port, + }); + + // Test database connection (pool pg existente) + const dbConnected = await testConnection(); + if (!dbConnected) { + logger.error('Failed to connect to database. Exiting...'); + process.exit(1); + } + + // Initialize TypeORM DataSource + const typeormConnected = await initializeTypeORM(); + if (!typeormConnected) { + logger.error('Failed to initialize TypeORM. Exiting...'); + process.exit(1); + } + + // Initialize Redis (opcional - no detiene la app si falla) + await initializeRedis(); + + // Start server + const server = app.listen(config.port, () => { + logger.info(`Server running on port ${config.port}`); + logger.info(`API available at http://localhost:${config.port}${config.apiPrefix}`); + logger.info(`Health check at http://localhost:${config.port}/health`); + }); + + // Graceful shutdown + const shutdown = async (signal: string) => { + logger.info(`Received ${signal}. Starting graceful shutdown...`); + + server.close(async () => { + logger.info('HTTP server closed'); + + // Cerrar conexiones en orden + await closeRedis(); + await closeTypeORM(); + await closePool(); + + logger.info('Shutdown complete'); + process.exit(0); + }); + + // Force shutdown after 10s + setTimeout(() => { + logger.error('Forced shutdown after timeout'); + process.exit(1); + }, 10000); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} + +bootstrap().catch((error) => { + logger.error('Failed to start server', { error: error.message }); + process.exit(1); +}); diff --git a/src/modules/auth/apiKeys.controller.ts b/src/modules/auth/apiKeys.controller.ts new file mode 100644 index 0000000..bb6cb71 --- /dev/null +++ b/src/modules/auth/apiKeys.controller.ts @@ -0,0 +1,331 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { apiKeysService, CreateApiKeyDto, UpdateApiKeyDto, ApiKeyFilters } from './apiKeys.service.js'; +import { AuthenticatedRequest, ValidationError, ApiResponse } from '../../shared/types/index.js'; + +// ============================================================================ +// VALIDATION SCHEMAS +// ============================================================================ + +const createApiKeySchema = z.object({ + name: z.string().min(1, 'Nombre requerido').max(255), + scope: z.string().max(100).optional(), + allowed_ips: z.array(z.string().ip()).optional(), + expiration_days: z.number().int().positive().max(365).optional(), +}); + +const updateApiKeySchema = z.object({ + name: z.string().min(1).max(255).optional(), + scope: z.string().max(100).nullable().optional(), + allowed_ips: z.array(z.string().ip()).nullable().optional(), + expiration_date: z.string().datetime().nullable().optional(), + is_active: z.boolean().optional(), +}); + +const listApiKeysSchema = z.object({ + user_id: z.string().uuid().optional(), + is_active: z.enum(['true', 'false']).optional(), + scope: z.string().optional(), +}); + +// ============================================================================ +// CONTROLLER +// ============================================================================ + +class ApiKeysController { + /** + * Create a new API key + * POST /api/auth/api-keys + */ + async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = createApiKeySchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const dto: CreateApiKeyDto = { + ...validation.data, + user_id: req.user!.userId, + tenant_id: req.user!.tenantId, + }; + + const result = await apiKeysService.create(dto); + + const response: ApiResponse = { + success: true, + data: result, + message: 'API key creada exitosamente. Guarde la clave, no podrá verla de nuevo.', + }; + + res.status(201).json(response); + } catch (error) { + next(error); + } + } + + /** + * List API keys for the current user + * GET /api/auth/api-keys + */ + async list(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = listApiKeysSchema.safeParse(req.query); + if (!validation.success) { + throw new ValidationError('Parámetros inválidos', validation.error.errors); + } + + const filters: ApiKeyFilters = { + tenant_id: req.user!.tenantId, + // By default, only show user's own keys unless admin + user_id: validation.data.user_id || req.user!.userId, + }; + + // Admins can view all keys in tenant + if (validation.data.user_id && req.user!.roles.includes('admin')) { + filters.user_id = validation.data.user_id; + } + + if (validation.data.is_active !== undefined) { + filters.is_active = validation.data.is_active === 'true'; + } + + if (validation.data.scope) { + filters.scope = validation.data.scope; + } + + const apiKeys = await apiKeysService.findAll(filters); + + const response: ApiResponse = { + success: true, + data: apiKeys, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Get a specific API key + * GET /api/auth/api-keys/:id + */ + async getById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + + const apiKey = await apiKeysService.findById(id, req.user!.tenantId); + + if (!apiKey) { + const response: ApiResponse = { + success: false, + error: 'API key no encontrada', + }; + res.status(404).json(response); + return; + } + + // Check ownership (unless admin) + if (apiKey.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) { + const response: ApiResponse = { + success: false, + error: 'No tiene permisos para ver esta API key', + }; + res.status(403).json(response); + return; + } + + const response: ApiResponse = { + success: true, + data: apiKey, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Update an API key + * PATCH /api/auth/api-keys/:id + */ + async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + + const validation = updateApiKeySchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + // Check ownership first + const existing = await apiKeysService.findById(id, req.user!.tenantId); + if (!existing) { + const response: ApiResponse = { + success: false, + error: 'API key no encontrada', + }; + res.status(404).json(response); + return; + } + + if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) { + const response: ApiResponse = { + success: false, + error: 'No tiene permisos para modificar esta API key', + }; + res.status(403).json(response); + return; + } + + const dto: UpdateApiKeyDto = { + ...validation.data, + expiration_date: validation.data.expiration_date + ? new Date(validation.data.expiration_date) + : validation.data.expiration_date === null + ? null + : undefined, + }; + + const updated = await apiKeysService.update(id, req.user!.tenantId, dto); + + const response: ApiResponse = { + success: true, + data: updated, + message: 'API key actualizada', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Revoke an API key (soft delete) + * POST /api/auth/api-keys/:id/revoke + */ + async revoke(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + + // Check ownership first + const existing = await apiKeysService.findById(id, req.user!.tenantId); + if (!existing) { + const response: ApiResponse = { + success: false, + error: 'API key no encontrada', + }; + res.status(404).json(response); + return; + } + + if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) { + const response: ApiResponse = { + success: false, + error: 'No tiene permisos para revocar esta API key', + }; + res.status(403).json(response); + return; + } + + await apiKeysService.revoke(id, req.user!.tenantId); + + const response: ApiResponse = { + success: true, + message: 'API key revocada exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Delete an API key permanently + * DELETE /api/auth/api-keys/:id + */ + async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + + // Check ownership first + const existing = await apiKeysService.findById(id, req.user!.tenantId); + if (!existing) { + const response: ApiResponse = { + success: false, + error: 'API key no encontrada', + }; + res.status(404).json(response); + return; + } + + if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) { + const response: ApiResponse = { + success: false, + error: 'No tiene permisos para eliminar esta API key', + }; + res.status(403).json(response); + return; + } + + await apiKeysService.delete(id, req.user!.tenantId); + + const response: ApiResponse = { + success: true, + message: 'API key eliminada permanentemente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Regenerate an API key (invalidates old key, creates new) + * POST /api/auth/api-keys/:id/regenerate + */ + async regenerate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + + // Check ownership first + const existing = await apiKeysService.findById(id, req.user!.tenantId); + if (!existing) { + const response: ApiResponse = { + success: false, + error: 'API key no encontrada', + }; + res.status(404).json(response); + return; + } + + if (existing.user_id !== req.user!.userId && !req.user!.roles.includes('admin')) { + const response: ApiResponse = { + success: false, + error: 'No tiene permisos para regenerar esta API key', + }; + res.status(403).json(response); + return; + } + + const result = await apiKeysService.regenerate(id, req.user!.tenantId); + + const response: ApiResponse = { + success: true, + data: result, + message: 'API key regenerada. Guarde la nueva clave, no podrá verla de nuevo.', + }; + + res.json(response); + } catch (error) { + next(error); + } + } +} + +export const apiKeysController = new ApiKeysController(); diff --git a/src/modules/auth/apiKeys.routes.ts b/src/modules/auth/apiKeys.routes.ts new file mode 100644 index 0000000..b6ea65d --- /dev/null +++ b/src/modules/auth/apiKeys.routes.ts @@ -0,0 +1,56 @@ +import { Router } from 'express'; +import { apiKeysController } from './apiKeys.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// ============================================================================ +// API KEY MANAGEMENT ROUTES +// ============================================================================ + +/** + * Create a new API key + * POST /api/auth/api-keys + */ +router.post('/', (req, res, next) => apiKeysController.create(req, res, next)); + +/** + * List API keys (user's own, or all for admins) + * GET /api/auth/api-keys + */ +router.get('/', (req, res, next) => apiKeysController.list(req, res, next)); + +/** + * Get a specific API key + * GET /api/auth/api-keys/:id + */ +router.get('/:id', (req, res, next) => apiKeysController.getById(req, res, next)); + +/** + * Update an API key + * PATCH /api/auth/api-keys/:id + */ +router.patch('/:id', (req, res, next) => apiKeysController.update(req, res, next)); + +/** + * Revoke an API key (soft delete) + * POST /api/auth/api-keys/:id/revoke + */ +router.post('/:id/revoke', (req, res, next) => apiKeysController.revoke(req, res, next)); + +/** + * Delete an API key permanently + * DELETE /api/auth/api-keys/:id + */ +router.delete('/:id', (req, res, next) => apiKeysController.delete(req, res, next)); + +/** + * Regenerate an API key + * POST /api/auth/api-keys/:id/regenerate + */ +router.post('/:id/regenerate', (req, res, next) => apiKeysController.regenerate(req, res, next)); + +export default router; diff --git a/src/modules/auth/apiKeys.service.ts b/src/modules/auth/apiKeys.service.ts new file mode 100644 index 0000000..784640a --- /dev/null +++ b/src/modules/auth/apiKeys.service.ts @@ -0,0 +1,491 @@ +import crypto from 'crypto'; +import { query, queryOne } from '../../config/database.js'; +import { ValidationError, NotFoundError, UnauthorizedError } from '../../shared/types/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface ApiKey { + id: string; + user_id: string; + tenant_id: string; + name: string; + key_index: string; + key_hash: string; + scope: string | null; + allowed_ips: string[] | null; + expiration_date: Date | null; + last_used_at: Date | null; + is_active: boolean; + created_at: Date; + updated_at: Date; +} + +export interface CreateApiKeyDto { + user_id: string; + tenant_id: string; + name: string; + scope?: string; + allowed_ips?: string[]; + expiration_days?: number; +} + +export interface UpdateApiKeyDto { + name?: string; + scope?: string; + allowed_ips?: string[]; + expiration_date?: Date | null; + is_active?: boolean; +} + +export interface ApiKeyWithPlainKey { + apiKey: Omit; + plainKey: string; +} + +export interface ApiKeyValidationResult { + valid: boolean; + apiKey?: ApiKey; + user?: { + id: string; + tenant_id: string; + email: string; + roles: string[]; + }; + error?: string; +} + +export interface ApiKeyFilters { + user_id?: string; + tenant_id?: string; + is_active?: boolean; + scope?: string; +} + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const API_KEY_PREFIX = 'mgn_'; +const KEY_LENGTH = 32; // 32 bytes = 256 bits +const HASH_ITERATIONS = 100000; +const HASH_KEYLEN = 64; +const HASH_DIGEST = 'sha512'; + +// ============================================================================ +// SERVICE +// ============================================================================ + +class ApiKeysService { + /** + * Generate a cryptographically secure API key + */ + private generatePlainKey(): string { + const randomBytes = crypto.randomBytes(KEY_LENGTH); + const key = randomBytes.toString('base64url'); + return `${API_KEY_PREFIX}${key}`; + } + + /** + * Extract the key index (first 16 chars after prefix) for lookup + */ + private getKeyIndex(plainKey: string): string { + const keyWithoutPrefix = plainKey.replace(API_KEY_PREFIX, ''); + return keyWithoutPrefix.substring(0, 16); + } + + /** + * Hash the API key using PBKDF2 + */ + private async hashKey(plainKey: string): Promise { + const salt = crypto.randomBytes(16).toString('hex'); + + return new Promise((resolve, reject) => { + crypto.pbkdf2( + plainKey, + salt, + HASH_ITERATIONS, + HASH_KEYLEN, + HASH_DIGEST, + (err, derivedKey) => { + if (err) reject(err); + resolve(`${salt}:${derivedKey.toString('hex')}`); + } + ); + }); + } + + /** + * Verify a plain key against a stored hash + */ + private async verifyKey(plainKey: string, storedHash: string): Promise { + const [salt, hash] = storedHash.split(':'); + + return new Promise((resolve, reject) => { + crypto.pbkdf2( + plainKey, + salt, + HASH_ITERATIONS, + HASH_KEYLEN, + HASH_DIGEST, + (err, derivedKey) => { + if (err) reject(err); + resolve(derivedKey.toString('hex') === hash); + } + ); + }); + } + + /** + * Create a new API key + * Returns the plain key only once - it cannot be retrieved later + */ + async create(dto: CreateApiKeyDto): Promise { + // Validate user exists + const user = await queryOne<{ id: string }>( + 'SELECT id FROM auth.users WHERE id = $1 AND tenant_id = $2', + [dto.user_id, dto.tenant_id] + ); + + if (!user) { + throw new ValidationError('Usuario no encontrado'); + } + + // Check for duplicate name + const existing = await queryOne<{ id: string }>( + 'SELECT id FROM auth.api_keys WHERE user_id = $1 AND name = $2', + [dto.user_id, dto.name] + ); + + if (existing) { + throw new ValidationError('Ya existe una API key con ese nombre'); + } + + // Generate key + const plainKey = this.generatePlainKey(); + const keyIndex = this.getKeyIndex(plainKey); + const keyHash = await this.hashKey(plainKey); + + // Calculate expiration date + let expirationDate: Date | null = null; + if (dto.expiration_days) { + expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + dto.expiration_days); + } + + // Insert API key + const apiKey = await queryOne( + `INSERT INTO auth.api_keys ( + user_id, tenant_id, name, key_index, key_hash, + scope, allowed_ips, expiration_date, is_active + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true) + RETURNING id, user_id, tenant_id, name, key_index, scope, + allowed_ips, expiration_date, is_active, created_at, updated_at`, + [ + dto.user_id, + dto.tenant_id, + dto.name, + keyIndex, + keyHash, + dto.scope || null, + dto.allowed_ips || null, + expirationDate, + ] + ); + + if (!apiKey) { + throw new Error('Error al crear API key'); + } + + logger.info('API key created', { + apiKeyId: apiKey.id, + userId: dto.user_id, + name: dto.name + }); + + return { + apiKey, + plainKey, // Only returned once! + }; + } + + /** + * Find all API keys for a user/tenant + */ + async findAll(filters: ApiKeyFilters): Promise[]> { + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (filters.user_id) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(filters.user_id); + } + + if (filters.tenant_id) { + conditions.push(`tenant_id = $${paramIndex++}`); + params.push(filters.tenant_id); + } + + if (filters.is_active !== undefined) { + conditions.push(`is_active = $${paramIndex++}`); + params.push(filters.is_active); + } + + if (filters.scope) { + conditions.push(`scope = $${paramIndex++}`); + params.push(filters.scope); + } + + const whereClause = conditions.length > 0 + ? `WHERE ${conditions.join(' AND ')}` + : ''; + + const apiKeys = await query( + `SELECT id, user_id, tenant_id, name, key_index, scope, + allowed_ips, expiration_date, last_used_at, is_active, + created_at, updated_at + FROM auth.api_keys + ${whereClause} + ORDER BY created_at DESC`, + params + ); + + return apiKeys; + } + + /** + * Find a specific API key by ID + */ + async findById(id: string, tenantId: string): Promise | null> { + const apiKey = await queryOne( + `SELECT id, user_id, tenant_id, name, key_index, scope, + allowed_ips, expiration_date, last_used_at, is_active, + created_at, updated_at + FROM auth.api_keys + WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + return apiKey; + } + + /** + * Update an API key + */ + async update(id: string, tenantId: string, dto: UpdateApiKeyDto): Promise> { + const existing = await this.findById(id, tenantId); + if (!existing) { + throw new NotFoundError('API key no encontrada'); + } + + const updates: string[] = ['updated_at = NOW()']; + const params: any[] = []; + let paramIndex = 1; + + if (dto.name !== undefined) { + updates.push(`name = $${paramIndex++}`); + params.push(dto.name); + } + + if (dto.scope !== undefined) { + updates.push(`scope = $${paramIndex++}`); + params.push(dto.scope); + } + + if (dto.allowed_ips !== undefined) { + updates.push(`allowed_ips = $${paramIndex++}`); + params.push(dto.allowed_ips); + } + + if (dto.expiration_date !== undefined) { + updates.push(`expiration_date = $${paramIndex++}`); + params.push(dto.expiration_date); + } + + if (dto.is_active !== undefined) { + updates.push(`is_active = $${paramIndex++}`); + params.push(dto.is_active); + } + + params.push(id); + params.push(tenantId); + + const updated = await queryOne( + `UPDATE auth.api_keys + SET ${updates.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} + RETURNING id, user_id, tenant_id, name, key_index, scope, + allowed_ips, expiration_date, last_used_at, is_active, + created_at, updated_at`, + params + ); + + if (!updated) { + throw new Error('Error al actualizar API key'); + } + + logger.info('API key updated', { apiKeyId: id }); + + return updated; + } + + /** + * Revoke (soft delete) an API key + */ + async revoke(id: string, tenantId: string): Promise { + const result = await query( + `UPDATE auth.api_keys + SET is_active = false, updated_at = NOW() + WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + if (!result) { + throw new NotFoundError('API key no encontrada'); + } + + logger.info('API key revoked', { apiKeyId: id }); + } + + /** + * Delete an API key permanently + */ + async delete(id: string, tenantId: string): Promise { + const result = await query( + 'DELETE FROM auth.api_keys WHERE id = $1 AND tenant_id = $2', + [id, tenantId] + ); + + logger.info('API key deleted', { apiKeyId: id }); + } + + /** + * Validate an API key and return the associated user info + * This is the main method used by the authentication middleware + */ + async validate(plainKey: string, clientIp?: string): Promise { + // Check prefix + if (!plainKey.startsWith(API_KEY_PREFIX)) { + return { valid: false, error: 'Formato de API key inválido' }; + } + + // Extract key index for lookup + const keyIndex = this.getKeyIndex(plainKey); + + // Find API key by index + const apiKey = await queryOne( + `SELECT * FROM auth.api_keys + WHERE key_index = $1 AND is_active = true`, + [keyIndex] + ); + + if (!apiKey) { + return { valid: false, error: 'API key no encontrada o inactiva' }; + } + + // Verify hash + const isValid = await this.verifyKey(plainKey, apiKey.key_hash); + if (!isValid) { + return { valid: false, error: 'API key inválida' }; + } + + // Check expiration + if (apiKey.expiration_date && new Date(apiKey.expiration_date) < new Date()) { + return { valid: false, error: 'API key expirada' }; + } + + // Check IP whitelist + if (apiKey.allowed_ips && apiKey.allowed_ips.length > 0 && clientIp) { + if (!apiKey.allowed_ips.includes(clientIp)) { + logger.warn('API key IP not allowed', { + apiKeyId: apiKey.id, + clientIp, + allowedIps: apiKey.allowed_ips + }); + return { valid: false, error: 'IP no autorizada' }; + } + } + + // Get user info with roles + const user = await queryOne<{ + id: string; + tenant_id: string; + email: string; + role_codes: string[]; + }>( + `SELECT u.id, u.tenant_id, u.email, array_agg(r.code) as role_codes + FROM auth.users u + LEFT JOIN auth.user_roles ur ON u.id = ur.user_id + LEFT JOIN auth.roles r ON ur.role_id = r.id + WHERE u.id = $1 AND u.status = 'active' + GROUP BY u.id`, + [apiKey.user_id] + ); + + if (!user) { + return { valid: false, error: 'Usuario asociado no encontrado o inactivo' }; + } + + // Update last used timestamp (async, don't wait) + query( + 'UPDATE auth.api_keys SET last_used_at = NOW() WHERE id = $1', + [apiKey.id] + ).catch(err => logger.error('Error updating last_used_at', { error: err })); + + return { + valid: true, + apiKey, + user: { + id: user.id, + tenant_id: user.tenant_id, + email: user.email, + roles: user.role_codes?.filter(Boolean) || [], + }, + }; + } + + /** + * Regenerate an API key (creates new key, invalidates old) + */ + async regenerate(id: string, tenantId: string): Promise { + const existing = await queryOne( + 'SELECT * FROM auth.api_keys WHERE id = $1 AND tenant_id = $2', + [id, tenantId] + ); + + if (!existing) { + throw new NotFoundError('API key no encontrada'); + } + + // Generate new key + const plainKey = this.generatePlainKey(); + const keyIndex = this.getKeyIndex(plainKey); + const keyHash = await this.hashKey(plainKey); + + // Update with new key + const updated = await queryOne( + `UPDATE auth.api_keys + SET key_index = $1, key_hash = $2, updated_at = NOW() + WHERE id = $3 AND tenant_id = $4 + RETURNING id, user_id, tenant_id, name, key_index, scope, + allowed_ips, expiration_date, is_active, created_at, updated_at`, + [keyIndex, keyHash, id, tenantId] + ); + + if (!updated) { + throw new Error('Error al regenerar API key'); + } + + logger.info('API key regenerated', { apiKeyId: id }); + + return { + apiKey: updated, + plainKey, + }; + } +} + +export const apiKeysService = new ApiKeysService(); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..5e6c5e0 --- /dev/null +++ b/src/modules/auth/auth.controller.ts @@ -0,0 +1,192 @@ +import { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { authService } from './auth.service.js'; +import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js'; + +// Validation schemas +const loginSchema = z.object({ + email: z.string().email('Email inválido'), + password: z.string().min(6, 'La contraseña debe tener al menos 6 caracteres'), +}); + +const registerSchema = z.object({ + email: z.string().email('Email inválido'), + password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'), + // Soporta ambos formatos: full_name (legacy) o firstName+lastName (frontend) + full_name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres').optional(), + firstName: z.string().min(2, 'Nombre debe tener al menos 2 caracteres').optional(), + lastName: z.string().min(2, 'Apellido debe tener al menos 2 caracteres').optional(), + tenant_id: z.string().uuid('Tenant ID inválido').optional(), + companyName: z.string().optional(), +}).refine( + (data) => data.full_name || (data.firstName && data.lastName), + { message: 'Se requiere full_name o firstName y lastName', path: ['full_name'] } +); + +const changePasswordSchema = z.object({ + current_password: z.string().min(1, 'Contraseña actual requerida'), + new_password: z.string().min(8, 'La nueva contraseña debe tener al menos 8 caracteres'), +}); + +const refreshTokenSchema = z.object({ + refresh_token: z.string().min(1, 'Refresh token requerido'), +}); + +export class AuthController { + async login(req: Request, res: Response, next: NextFunction): Promise { + try { + const validation = loginSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + // Extract request metadata for session tracking + const metadata = { + ipAddress: req.ip || req.socket.remoteAddress || 'unknown', + userAgent: req.get('User-Agent') || 'unknown', + }; + + const result = await authService.login({ + ...validation.data, + metadata, + }); + + const response: ApiResponse = { + success: true, + data: result, + message: 'Inicio de sesión exitoso', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async register(req: Request, res: Response, next: NextFunction): Promise { + try { + const validation = registerSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const result = await authService.register(validation.data); + + const response: ApiResponse = { + success: true, + data: result, + message: 'Usuario registrado exitosamente', + }; + + res.status(201).json(response); + } catch (error) { + next(error); + } + } + + async refreshToken(req: Request, res: Response, next: NextFunction): Promise { + try { + const validation = refreshTokenSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + // Extract request metadata for session tracking + const metadata = { + ipAddress: req.ip || req.socket.remoteAddress || 'unknown', + userAgent: req.get('User-Agent') || 'unknown', + }; + + const tokens = await authService.refreshToken(validation.data.refresh_token, metadata); + + const response: ApiResponse = { + success: true, + data: { tokens }, + message: 'Token renovado exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async changePassword(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = changePasswordSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const userId = req.user!.userId; + await authService.changePassword( + userId, + validation.data.current_password, + validation.data.new_password + ); + + const response: ApiResponse = { + success: true, + message: 'Contraseña actualizada exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async getProfile(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user!.userId; + const profile = await authService.getProfile(userId); + + const response: ApiResponse = { + success: true, + data: profile, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async logout(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + // sessionId can come from body (sent by client after login) + const sessionId = req.body?.sessionId; + if (sessionId) { + await authService.logout(sessionId); + } + + const response: ApiResponse = { + success: true, + message: 'Sesión cerrada exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async logoutAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.user!.userId; + const sessionsRevoked = await authService.logoutAll(userId); + + const response: ApiResponse = { + success: true, + data: { sessionsRevoked }, + message: 'Todas las sesiones han sido cerradas', + }; + + res.json(response); + } catch (error) { + next(error); + } + } +} + +export const authController = new AuthController(); diff --git a/src/modules/auth/auth.routes.ts b/src/modules/auth/auth.routes.ts new file mode 100644 index 0000000..6194e6b --- /dev/null +++ b/src/modules/auth/auth.routes.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { authController } from './auth.controller.js'; +import { authenticate } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// Public routes +router.post('/login', (req, res, next) => authController.login(req, res, next)); +router.post('/register', (req, res, next) => authController.register(req, res, next)); +router.post('/refresh', (req, res, next) => authController.refreshToken(req, res, next)); + +// Protected routes +router.get('/profile', authenticate, (req, res, next) => authController.getProfile(req, res, next)); +router.post('/change-password', authenticate, (req, res, next) => authController.changePassword(req, res, next)); +router.post('/logout', authenticate, (req, res, next) => authController.logout(req, res, next)); +router.post('/logout-all', authenticate, (req, res, next) => authController.logoutAll(req, res, next)); + +export default router; diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..43efe10 --- /dev/null +++ b/src/modules/auth/auth.service.ts @@ -0,0 +1,234 @@ +import bcrypt from 'bcryptjs'; +import { Repository } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { User, UserStatus, Role } from './entities/index.js'; +import { tokenService, TokenPair, RequestMetadata } from './services/token.service.js'; +import { UnauthorizedError, ValidationError, NotFoundError } from '../../shared/types/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +export interface LoginDto { + email: string; + password: string; + metadata?: RequestMetadata; // IP and user agent for session tracking +} + +export interface RegisterDto { + email: string; + password: string; + // Soporta ambos formatos para compatibilidad frontend/backend + full_name?: string; + firstName?: string; + lastName?: string; + tenant_id?: string; + companyName?: string; +} + +/** + * Transforma full_name a firstName/lastName para respuesta al frontend + */ +export function splitFullName(fullName: string): { firstName: string; lastName: string } { + const parts = fullName.trim().split(/\s+/); + if (parts.length === 1) { + return { firstName: parts[0], lastName: '' }; + } + const firstName = parts[0]; + const lastName = parts.slice(1).join(' '); + return { firstName, lastName }; +} + +/** + * Transforma firstName/lastName a full_name para almacenar en BD + */ +export function buildFullName(firstName?: string, lastName?: string, fullName?: string): string { + if (fullName) return fullName.trim(); + return `${firstName || ''} ${lastName || ''}`.trim(); +} + +export interface LoginResponse { + user: Omit & { firstName: string; lastName: string }; + tokens: TokenPair; +} + +class AuthService { + private userRepository: Repository; + + constructor() { + this.userRepository = AppDataSource.getRepository(User); + } + + async login(dto: LoginDto): Promise { + // Find user by email using TypeORM + const user = await this.userRepository.findOne({ + where: { email: dto.email.toLowerCase(), status: UserStatus.ACTIVE }, + relations: ['roles'], + }); + + if (!user) { + throw new UnauthorizedError('Credenciales inválidas'); + } + + // Verify password + const isValidPassword = await bcrypt.compare(dto.password, user.passwordHash || ''); + if (!isValidPassword) { + throw new UnauthorizedError('Credenciales inválidas'); + } + + // Update last login + user.lastLoginAt = new Date(); + user.loginCount += 1; + if (dto.metadata?.ipAddress) { + user.lastLoginIp = dto.metadata.ipAddress; + } + await this.userRepository.save(user); + + // Generate token pair using TokenService + const metadata: RequestMetadata = dto.metadata || { + ipAddress: 'unknown', + userAgent: 'unknown', + }; + const tokens = await tokenService.generateTokenPair(user, metadata); + + // Transform fullName to firstName/lastName for frontend response + const { firstName, lastName } = splitFullName(user.fullName); + + // Remove passwordHash from response and add firstName/lastName + const { passwordHash, ...userWithoutPassword } = user; + const userResponse = { + ...userWithoutPassword, + firstName, + lastName, + }; + + logger.info('User logged in', { userId: user.id, email: user.email }); + + return { + user: userResponse as any, + tokens, + }; + } + + async register(dto: RegisterDto): Promise { + // Check if email already exists using TypeORM + const existingUser = await this.userRepository.findOne({ + where: { email: dto.email.toLowerCase() }, + }); + + if (existingUser) { + throw new ValidationError('El email ya está registrado'); + } + + // Transform firstName/lastName to fullName for database storage + const fullName = buildFullName(dto.firstName, dto.lastName, dto.full_name); + + // Hash password + const passwordHash = await bcrypt.hash(dto.password, 10); + + // Generate tenantId if not provided (new company registration) + const tenantId = dto.tenant_id || crypto.randomUUID(); + + // Create user using TypeORM + const newUser = this.userRepository.create({ + email: dto.email.toLowerCase(), + passwordHash, + fullName, + tenantId, + status: UserStatus.ACTIVE, + }); + + await this.userRepository.save(newUser); + + // Load roles relation for token generation + const userWithRoles = await this.userRepository.findOne({ + where: { id: newUser.id }, + relations: ['roles'], + }); + + if (!userWithRoles) { + throw new Error('Error al crear usuario'); + } + + // Generate token pair using TokenService + const metadata: RequestMetadata = { + ipAddress: 'unknown', + userAgent: 'unknown', + }; + const tokens = await tokenService.generateTokenPair(userWithRoles, metadata); + + // Transform fullName to firstName/lastName for frontend response + const { firstName, lastName } = splitFullName(userWithRoles.fullName); + + // Remove passwordHash from response and add firstName/lastName + const { passwordHash: _, ...userWithoutPassword } = userWithRoles; + const userResponse = { + ...userWithoutPassword, + firstName, + lastName, + }; + + logger.info('User registered', { userId: userWithRoles.id, email: userWithRoles.email }); + + return { + user: userResponse as any, + tokens, + }; + } + + async refreshToken(refreshToken: string, metadata: RequestMetadata): Promise { + // Delegate completely to TokenService + return tokenService.refreshTokens(refreshToken, metadata); + } + + async logout(sessionId: string): Promise { + await tokenService.revokeSession(sessionId, 'user_logout'); + } + + async logoutAll(userId: string): Promise { + return tokenService.revokeAllUserSessions(userId, 'logout_all'); + } + + async changePassword(userId: string, currentPassword: string, newPassword: string): Promise { + // Find user using TypeORM + const user = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (!user) { + throw new NotFoundError('Usuario no encontrado'); + } + + // Verify current password + const isValidPassword = await bcrypt.compare(currentPassword, user.passwordHash || ''); + if (!isValidPassword) { + throw new UnauthorizedError('Contraseña actual incorrecta'); + } + + // Hash new password and update user + const newPasswordHash = await bcrypt.hash(newPassword, 10); + user.passwordHash = newPasswordHash; + user.updatedAt = new Date(); + await this.userRepository.save(user); + + // Revoke all sessions after password change for security + const revokedCount = await tokenService.revokeAllUserSessions(userId, 'password_changed'); + + logger.info('Password changed and all sessions revoked', { userId, revokedCount }); + } + + async getProfile(userId: string): Promise> { + // Find user using TypeORM with relations + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['roles', 'companies'], + }); + + if (!user) { + throw new NotFoundError('Usuario no encontrado'); + } + + // Remove passwordHash from response + const { passwordHash, ...userWithoutPassword } = user; + return userWithoutPassword; + } +} + +export const authService = new AuthService(); diff --git a/src/modules/auth/entities/api-key.entity.ts b/src/modules/auth/entities/api-key.entity.ts new file mode 100644 index 0000000..418fe2a --- /dev/null +++ b/src/modules/auth/entities/api-key.entity.ts @@ -0,0 +1,87 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from './user.entity.js'; +import { Tenant } from './tenant.entity.js'; + +@Entity({ schema: 'auth', name: 'api_keys' }) +@Index('idx_api_keys_lookup', ['keyIndex', 'isActive'], { + where: 'is_active = TRUE', +}) +@Index('idx_api_keys_expiration', ['expirationDate'], { + where: 'expiration_date IS NOT NULL', +}) +@Index('idx_api_keys_user', ['userId']) +@Index('idx_api_keys_tenant', ['tenantId']) +export class ApiKey { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'user_id' }) + userId: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + // Descripción + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + // Seguridad + @Column({ type: 'varchar', length: 16, nullable: false, name: 'key_index' }) + keyIndex: string; + + @Column({ type: 'varchar', length: 255, nullable: false, name: 'key_hash' }) + keyHash: string; + + // Scope y restricciones + @Column({ type: 'varchar', length: 100, nullable: true }) + scope: string | null; + + @Column({ type: 'inet', array: true, nullable: true, name: 'allowed_ips' }) + allowedIps: string[] | null; + + // Expiración + @Column({ + type: 'timestamptz', + nullable: true, + name: 'expiration_date', + }) + expirationDate: Date | null; + + @Column({ type: 'timestamptz', nullable: true, name: 'last_used_at' }) + lastUsedAt: Date | null; + + // Estado + @Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' }) + isActive: boolean; + + // Relaciones + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @ManyToOne(() => Tenant, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'revoked_by' }) + revokedByUser: User | null; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ type: 'timestamptz', nullable: true, name: 'revoked_at' }) + revokedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'revoked_by' }) + revokedBy: string | null; +} diff --git a/src/modules/auth/entities/company.entity.ts b/src/modules/auth/entities/company.entity.ts new file mode 100644 index 0000000..b5bdd70 --- /dev/null +++ b/src/modules/auth/entities/company.entity.ts @@ -0,0 +1,93 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + ManyToMany, +} from 'typeorm'; +import { Tenant } from './tenant.entity.js'; +import { User } from './user.entity.js'; + +@Entity({ schema: 'auth', name: 'companies' }) +@Index('idx_companies_tenant_id', ['tenantId']) +@Index('idx_companies_parent_company_id', ['parentCompanyId']) +@Index('idx_companies_active', ['tenantId'], { where: 'deleted_at IS NULL' }) +@Index('idx_companies_tax_id', ['taxId']) +export class Company { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 255, nullable: true, name: 'legal_name' }) + legalName: string | null; + + @Column({ type: 'varchar', length: 50, nullable: true, name: 'tax_id' }) + taxId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'currency_id' }) + currencyId: string | null; + + @Column({ + type: 'uuid', + nullable: true, + name: 'parent_company_id', + }) + parentCompanyId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'partner_id' }) + partnerId: string | null; + + @Column({ type: 'jsonb', default: {} }) + settings: Record; + + // Relaciones + @ManyToOne(() => Tenant, (tenant) => tenant.companies, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Company, (company) => company.childCompanies, { + nullable: true, + }) + @JoinColumn({ name: 'parent_company_id' }) + parentCompany: Company | null; + + @ManyToMany(() => Company) + childCompanies: Company[]; + + @ManyToMany(() => User, (user) => user.companies) + users: User[]; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + deletedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) + deletedBy: string | null; +} diff --git a/src/modules/auth/entities/group.entity.ts b/src/modules/auth/entities/group.entity.ts new file mode 100644 index 0000000..c616efd --- /dev/null +++ b/src/modules/auth/entities/group.entity.ts @@ -0,0 +1,89 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from './tenant.entity.js'; +import { User } from './user.entity.js'; + +@Entity({ schema: 'auth', name: 'groups' }) +@Index('idx_groups_tenant_id', ['tenantId']) +@Index('idx_groups_code', ['code']) +@Index('idx_groups_category', ['category']) +@Index('idx_groups_is_system', ['isSystem']) +export class Group { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + code: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + // Configuración + @Column({ type: 'boolean', default: false, nullable: false, name: 'is_system' }) + isSystem: boolean; + + @Column({ type: 'varchar', length: 100, nullable: true }) + category: string | null; + + @Column({ type: 'varchar', length: 20, nullable: true }) + color: string | null; + + // API Keys + @Column({ + type: 'integer', + default: 30, + nullable: true, + name: 'api_key_max_duration_days', + }) + apiKeyMaxDurationDays: number | null; + + // Relaciones + @ManyToOne(() => Tenant, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'created_by' }) + createdByUser: User | null; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'updated_by' }) + updatedByUser: User | null; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'deleted_by' }) + deletedByUser: User | null; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', nullable: true }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + deletedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) + deletedBy: string | null; +} diff --git a/src/modules/auth/entities/index.ts b/src/modules/auth/entities/index.ts new file mode 100644 index 0000000..1987270 --- /dev/null +++ b/src/modules/auth/entities/index.ts @@ -0,0 +1,15 @@ +export { Tenant, TenantStatus } from './tenant.entity.js'; +export { Company } from './company.entity.js'; +export { User, UserStatus } from './user.entity.js'; +export { Role } from './role.entity.js'; +export { Permission, PermissionAction } from './permission.entity.js'; +export { Session, SessionStatus } from './session.entity.js'; +export { PasswordReset } from './password-reset.entity.js'; +export { Group } from './group.entity.js'; +export { ApiKey } from './api-key.entity.js'; +export { TrustedDevice, TrustLevel } from './trusted-device.entity.js'; +export { VerificationCode, CodeType } from './verification-code.entity.js'; +export { MfaAuditLog, MfaEventType } from './mfa-audit-log.entity.js'; +export { OAuthProvider } from './oauth-provider.entity.js'; +export { OAuthUserLink } from './oauth-user-link.entity.js'; +export { OAuthState } from './oauth-state.entity.js'; diff --git a/src/modules/auth/entities/mfa-audit-log.entity.ts b/src/modules/auth/entities/mfa-audit-log.entity.ts new file mode 100644 index 0000000..c9b6367 --- /dev/null +++ b/src/modules/auth/entities/mfa-audit-log.entity.ts @@ -0,0 +1,87 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from './user.entity.js'; + +export enum MfaEventType { + MFA_SETUP_INITIATED = 'mfa_setup_initiated', + MFA_SETUP_COMPLETED = 'mfa_setup_completed', + MFA_DISABLED = 'mfa_disabled', + TOTP_VERIFIED = 'totp_verified', + TOTP_FAILED = 'totp_failed', + BACKUP_CODE_USED = 'backup_code_used', + BACKUP_CODES_REGENERATED = 'backup_codes_regenerated', + DEVICE_TRUSTED = 'device_trusted', + DEVICE_REVOKED = 'device_revoked', + ANOMALY_DETECTED = 'anomaly_detected', + ACCOUNT_LOCKED = 'account_locked', + ACCOUNT_UNLOCKED = 'account_unlocked', +} + +@Entity({ schema: 'auth', name: 'mfa_audit_log' }) +@Index('idx_mfa_audit_user', ['userId', 'createdAt']) +@Index('idx_mfa_audit_event', ['eventType', 'createdAt']) +@Index('idx_mfa_audit_failures', ['userId', 'createdAt'], { + where: 'success = FALSE', +}) +export class MfaAuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + // Usuario + @Column({ type: 'uuid', nullable: false, name: 'user_id' }) + userId: string; + + // Evento + @Column({ + type: 'enum', + enum: MfaEventType, + nullable: false, + name: 'event_type', + }) + eventType: MfaEventType; + + // Resultado + @Column({ type: 'boolean', nullable: false }) + success: boolean; + + @Column({ type: 'varchar', length: 128, nullable: true, name: 'failure_reason' }) + failureReason: string | null; + + // Contexto + @Column({ type: 'inet', nullable: true, name: 'ip_address' }) + ipAddress: string | null; + + @Column({ type: 'text', nullable: true, name: 'user_agent' }) + userAgent: string | null; + + @Column({ + type: 'varchar', + length: 128, + nullable: true, + name: 'device_fingerprint', + }) + deviceFingerprint: string | null; + + @Column({ type: 'jsonb', nullable: true }) + location: Record | null; + + // Metadata adicional + @Column({ type: 'jsonb', default: {}, nullable: true }) + metadata: Record; + + // Relaciones + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user: User; + + // Timestamp + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/auth/entities/oauth-provider.entity.ts b/src/modules/auth/entities/oauth-provider.entity.ts new file mode 100644 index 0000000..d019d86 --- /dev/null +++ b/src/modules/auth/entities/oauth-provider.entity.ts @@ -0,0 +1,191 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from './tenant.entity.js'; +import { User } from './user.entity.js'; +import { Role } from './role.entity.js'; + +@Entity({ schema: 'auth', name: 'oauth_providers' }) +@Index('idx_oauth_providers_enabled', ['isEnabled']) +@Index('idx_oauth_providers_tenant', ['tenantId']) +@Index('idx_oauth_providers_code', ['code']) +export class OAuthProvider { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: true, name: 'tenant_id' }) + tenantId: string | null; + + @Column({ type: 'varchar', length: 50, nullable: false, unique: true }) + code: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + // Configuración OAuth2 + @Column({ type: 'varchar', length: 255, nullable: false, name: 'client_id' }) + clientId: string; + + @Column({ type: 'varchar', length: 500, nullable: true, name: 'client_secret' }) + clientSecret: string | null; + + // Endpoints OAuth2 + @Column({ + type: 'varchar', + length: 500, + nullable: false, + name: 'authorization_endpoint', + }) + authorizationEndpoint: string; + + @Column({ + type: 'varchar', + length: 500, + nullable: false, + name: 'token_endpoint', + }) + tokenEndpoint: string; + + @Column({ + type: 'varchar', + length: 500, + nullable: false, + name: 'userinfo_endpoint', + }) + userinfoEndpoint: string; + + @Column({ type: 'varchar', length: 500, nullable: true, name: 'jwks_uri' }) + jwksUri: string | null; + + // Scopes y parámetros + @Column({ + type: 'varchar', + length: 500, + default: 'openid profile email', + nullable: false, + }) + scope: string; + + @Column({ + type: 'varchar', + length: 50, + default: 'code', + nullable: false, + name: 'response_type', + }) + responseType: string; + + // PKCE Configuration + @Column({ + type: 'boolean', + default: true, + nullable: false, + name: 'pkce_enabled', + }) + pkceEnabled: boolean; + + @Column({ + type: 'varchar', + length: 10, + default: 'S256', + nullable: true, + name: 'code_challenge_method', + }) + codeChallengeMethod: string | null; + + // Mapeo de claims + @Column({ + type: 'jsonb', + nullable: false, + name: 'claim_mapping', + default: { + sub: 'oauth_uid', + email: 'email', + name: 'name', + picture: 'avatar_url', + }, + }) + claimMapping: Record; + + // UI + @Column({ type: 'varchar', length: 100, nullable: true, name: 'icon_class' }) + iconClass: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true, name: 'button_text' }) + buttonText: string | null; + + @Column({ type: 'varchar', length: 20, nullable: true, name: 'button_color' }) + buttonColor: string | null; + + @Column({ + type: 'integer', + default: 10, + nullable: false, + name: 'display_order', + }) + displayOrder: number; + + // Estado + @Column({ type: 'boolean', default: false, nullable: false, name: 'is_enabled' }) + isEnabled: boolean; + + @Column({ type: 'boolean', default: true, nullable: false, name: 'is_visible' }) + isVisible: boolean; + + // Restricciones + @Column({ + type: 'text', + array: true, + nullable: true, + name: 'allowed_domains', + }) + allowedDomains: string[] | null; + + @Column({ + type: 'boolean', + default: false, + nullable: false, + name: 'auto_create_users', + }) + autoCreateUsers: boolean; + + @Column({ type: 'uuid', nullable: true, name: 'default_role_id' }) + defaultRoleId: string | null; + + // Relaciones + @ManyToOne(() => Tenant, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant | null; + + @ManyToOne(() => Role, { nullable: true }) + @JoinColumn({ name: 'default_role_id' }) + defaultRole: Role | null; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'created_by' }) + createdByUser: User | null; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'updated_by' }) + updatedByUser: User | null; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; +} diff --git a/src/modules/auth/entities/oauth-state.entity.ts b/src/modules/auth/entities/oauth-state.entity.ts new file mode 100644 index 0000000..f5d0481 --- /dev/null +++ b/src/modules/auth/entities/oauth-state.entity.ts @@ -0,0 +1,66 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { OAuthProvider } from './oauth-provider.entity.js'; +import { User } from './user.entity.js'; + +@Entity({ schema: 'auth', name: 'oauth_states' }) +@Index('idx_oauth_states_state', ['state']) +@Index('idx_oauth_states_expires', ['expiresAt']) +export class OAuthState { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 64, nullable: false, unique: true }) + state: string; + + // PKCE + @Column({ type: 'varchar', length: 128, nullable: true, name: 'code_verifier' }) + codeVerifier: string | null; + + // Contexto + @Column({ type: 'uuid', nullable: false, name: 'provider_id' }) + providerId: string; + + @Column({ type: 'varchar', length: 500, nullable: false, name: 'redirect_uri' }) + redirectUri: string; + + @Column({ type: 'varchar', length: 500, nullable: true, name: 'return_url' }) + returnUrl: string | null; + + // Vinculación con usuario existente (para linking) + @Column({ type: 'uuid', nullable: true, name: 'link_user_id' }) + linkUserId: string | null; + + // Metadata + @Column({ type: 'inet', nullable: true, name: 'ip_address' }) + ipAddress: string | null; + + @Column({ type: 'text', nullable: true, name: 'user_agent' }) + userAgent: string | null; + + // Relaciones + @ManyToOne(() => OAuthProvider) + @JoinColumn({ name: 'provider_id' }) + provider: OAuthProvider; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'link_user_id' }) + linkUser: User | null; + + // Tiempo de vida + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ type: 'timestamptz', nullable: false, name: 'expires_at' }) + expiresAt: Date; + + @Column({ type: 'timestamptz', nullable: true, name: 'used_at' }) + usedAt: Date | null; +} diff --git a/src/modules/auth/entities/oauth-user-link.entity.ts b/src/modules/auth/entities/oauth-user-link.entity.ts new file mode 100644 index 0000000..d75f529 --- /dev/null +++ b/src/modules/auth/entities/oauth-user-link.entity.ts @@ -0,0 +1,73 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from './user.entity.js'; +import { OAuthProvider } from './oauth-provider.entity.js'; + +@Entity({ schema: 'auth', name: 'oauth_user_links' }) +@Index('idx_oauth_links_user', ['userId']) +@Index('idx_oauth_links_provider', ['providerId']) +@Index('idx_oauth_links_oauth_uid', ['oauthUid']) +export class OAuthUserLink { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'user_id' }) + userId: string; + + @Column({ type: 'uuid', nullable: false, name: 'provider_id' }) + providerId: string; + + // Identificación OAuth + @Column({ type: 'varchar', length: 255, nullable: false, name: 'oauth_uid' }) + oauthUid: string; + + @Column({ type: 'varchar', length: 255, nullable: true, name: 'oauth_email' }) + oauthEmail: string | null; + + // Tokens (encriptados) + @Column({ type: 'text', nullable: true, name: 'access_token' }) + accessToken: string | null; + + @Column({ type: 'text', nullable: true, name: 'refresh_token' }) + refreshToken: string | null; + + @Column({ type: 'text', nullable: true, name: 'id_token' }) + idToken: string | null; + + @Column({ type: 'timestamptz', nullable: true, name: 'token_expires_at' }) + tokenExpiresAt: Date | null; + + // Metadata + @Column({ type: 'jsonb', nullable: true, name: 'raw_userinfo' }) + rawUserinfo: Record | null; + + @Column({ type: 'timestamptz', nullable: true, name: 'last_login_at' }) + lastLoginAt: Date | null; + + @Column({ type: 'integer', default: 0, nullable: false, name: 'login_count' }) + loginCount: number; + + // Relaciones + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @ManyToOne(() => OAuthProvider, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'provider_id' }) + provider: OAuthProvider; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/auth/entities/password-reset.entity.ts b/src/modules/auth/entities/password-reset.entity.ts new file mode 100644 index 0000000..79ac700 --- /dev/null +++ b/src/modules/auth/entities/password-reset.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from './user.entity.js'; + +@Entity({ schema: 'auth', name: 'password_resets' }) +@Index('idx_password_resets_user_id', ['userId']) +@Index('idx_password_resets_token', ['token']) +@Index('idx_password_resets_expires_at', ['expiresAt']) +export class PasswordReset { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'user_id' }) + userId: string; + + @Column({ type: 'varchar', length: 500, unique: true, nullable: false }) + token: string; + + @Column({ type: 'timestamp', nullable: false, name: 'expires_at' }) + expiresAt: Date; + + @Column({ type: 'timestamp', nullable: true, name: 'used_at' }) + usedAt: Date | null; + + @Column({ type: 'inet', nullable: true, name: 'ip_address' }) + ipAddress: string | null; + + // Relaciones + @ManyToOne(() => User, (user) => user.passwordResets, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'user_id' }) + user: User; + + // Timestamps + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} diff --git a/src/modules/auth/entities/permission.entity.ts b/src/modules/auth/entities/permission.entity.ts new file mode 100644 index 0000000..e67566c --- /dev/null +++ b/src/modules/auth/entities/permission.entity.ts @@ -0,0 +1,52 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToMany, +} from 'typeorm'; +import { Role } from './role.entity.js'; + +export enum PermissionAction { + CREATE = 'create', + READ = 'read', + UPDATE = 'update', + DELETE = 'delete', + APPROVE = 'approve', + CANCEL = 'cancel', + EXPORT = 'export', +} + +@Entity({ schema: 'auth', name: 'permissions' }) +@Index('idx_permissions_resource', ['resource']) +@Index('idx_permissions_action', ['action']) +@Index('idx_permissions_module', ['module']) +export class Permission { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + resource: string; + + @Column({ + type: 'enum', + enum: PermissionAction, + nullable: false, + }) + action: PermissionAction; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'varchar', length: 50, nullable: true }) + module: string | null; + + // Relaciones + @ManyToMany(() => Role, (role) => role.permissions) + roles: Role[]; + + // Sin tenant_id: permisos son globales + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} diff --git a/src/modules/auth/entities/role.entity.ts b/src/modules/auth/entities/role.entity.ts new file mode 100644 index 0000000..670c7e6 --- /dev/null +++ b/src/modules/auth/entities/role.entity.ts @@ -0,0 +1,84 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + ManyToMany, + JoinColumn, + JoinTable, +} from 'typeorm'; +import { Tenant } from './tenant.entity.js'; +import { User } from './user.entity.js'; +import { Permission } from './permission.entity.js'; + +@Entity({ schema: 'auth', name: 'roles' }) +@Index('idx_roles_tenant_id', ['tenantId']) +@Index('idx_roles_code', ['code']) +@Index('idx_roles_is_system', ['isSystem']) +export class Role { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 50, nullable: false }) + code: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'boolean', default: false, nullable: false, name: 'is_system' }) + isSystem: boolean; + + @Column({ type: 'varchar', length: 20, nullable: true }) + color: string | null; + + // Relaciones + @ManyToOne(() => Tenant, (tenant) => tenant.roles, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToMany(() => Permission, (permission) => permission.roles) + @JoinTable({ + name: 'role_permissions', + schema: 'auth', + joinColumn: { name: 'role_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' }, + }) + permissions: Permission[]; + + @ManyToMany(() => User, (user) => user.roles) + users: User[]; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + deletedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) + deletedBy: string | null; +} diff --git a/src/modules/auth/entities/session.entity.ts b/src/modules/auth/entities/session.entity.ts new file mode 100644 index 0000000..b34c19d --- /dev/null +++ b/src/modules/auth/entities/session.entity.ts @@ -0,0 +1,90 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from './user.entity.js'; + +export enum SessionStatus { + ACTIVE = 'active', + EXPIRED = 'expired', + REVOKED = 'revoked', +} + +@Entity({ schema: 'auth', name: 'sessions' }) +@Index('idx_sessions_user_id', ['userId']) +@Index('idx_sessions_token', ['token']) +@Index('idx_sessions_status', ['status']) +@Index('idx_sessions_expires_at', ['expiresAt']) +export class Session { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'user_id' }) + userId: string; + + @Column({ type: 'varchar', length: 500, unique: true, nullable: false }) + token: string; + + @Column({ + type: 'varchar', + length: 500, + unique: true, + nullable: true, + name: 'refresh_token', + }) + refreshToken: string | null; + + @Column({ + type: 'enum', + enum: SessionStatus, + default: SessionStatus.ACTIVE, + nullable: false, + }) + status: SessionStatus; + + @Column({ type: 'timestamp', nullable: false, name: 'expires_at' }) + expiresAt: Date; + + @Column({ + type: 'timestamp', + nullable: true, + name: 'refresh_expires_at', + }) + refreshExpiresAt: Date | null; + + @Column({ type: 'inet', nullable: true, name: 'ip_address' }) + ipAddress: string | null; + + @Column({ type: 'text', nullable: true, name: 'user_agent' }) + userAgent: string | null; + + @Column({ type: 'jsonb', nullable: true, name: 'device_info' }) + deviceInfo: Record | null; + + // Relaciones + @ManyToOne(() => User, (user) => user.sessions, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'user_id' }) + user: User; + + // Timestamps + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'timestamp', nullable: true, name: 'revoked_at' }) + revokedAt: Date | null; + + @Column({ + type: 'varchar', + length: 100, + nullable: true, + name: 'revoked_reason', + }) + revokedReason: string | null; +} diff --git a/src/modules/auth/entities/tenant.entity.ts b/src/modules/auth/entities/tenant.entity.ts new file mode 100644 index 0000000..2d0d447 --- /dev/null +++ b/src/modules/auth/entities/tenant.entity.ts @@ -0,0 +1,93 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { Company } from './company.entity.js'; +import { User } from './user.entity.js'; +import { Role } from './role.entity.js'; + +export enum TenantStatus { + ACTIVE = 'active', + SUSPENDED = 'suspended', + TRIAL = 'trial', + CANCELLED = 'cancelled', +} + +@Entity({ schema: 'auth', name: 'tenants' }) +@Index('idx_tenants_subdomain', ['subdomain']) +@Index('idx_tenants_status', ['status'], { where: 'deleted_at IS NULL' }) +@Index('idx_tenants_created_at', ['createdAt']) +export class Tenant { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 100, unique: true, nullable: false }) + subdomain: string; + + @Column({ + type: 'varchar', + length: 100, + unique: true, + nullable: false, + name: 'schema_name', + }) + schemaName: string; + + @Column({ + type: 'enum', + enum: TenantStatus, + default: TenantStatus.ACTIVE, + nullable: false, + }) + status: TenantStatus; + + @Column({ type: 'jsonb', default: {} }) + settings: Record; + + @Column({ type: 'varchar', length: 50, default: 'basic', nullable: true }) + plan: string; + + @Column({ type: 'integer', default: 10, name: 'max_users' }) + maxUsers: number; + + // Relaciones + @OneToMany(() => Company, (company) => company.tenant) + companies: Company[]; + + @OneToMany(() => User, (user) => user.tenant) + users: User[]; + + @OneToMany(() => Role, (role) => role.tenant) + roles: Role[]; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + deletedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) + deletedBy: string | null; +} diff --git a/src/modules/auth/entities/trusted-device.entity.ts b/src/modules/auth/entities/trusted-device.entity.ts new file mode 100644 index 0000000..5c5b81f --- /dev/null +++ b/src/modules/auth/entities/trusted-device.entity.ts @@ -0,0 +1,115 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from './user.entity.js'; + +export enum TrustLevel { + STANDARD = 'standard', + HIGH = 'high', + TEMPORARY = 'temporary', +} + +@Entity({ schema: 'auth', name: 'trusted_devices' }) +@Index('idx_trusted_devices_user', ['userId'], { where: 'is_active' }) +@Index('idx_trusted_devices_fingerprint', ['deviceFingerprint']) +@Index('idx_trusted_devices_expires', ['trustExpiresAt'], { + where: 'trust_expires_at IS NOT NULL AND is_active', +}) +export class TrustedDevice { + @PrimaryGeneratedColumn('uuid') + id: string; + + // Relación con usuario + @Column({ type: 'uuid', nullable: false, name: 'user_id' }) + userId: string; + + // Identificación del dispositivo + @Column({ + type: 'varchar', + length: 128, + nullable: false, + name: 'device_fingerprint', + }) + deviceFingerprint: string; + + @Column({ type: 'varchar', length: 128, nullable: true, name: 'device_name' }) + deviceName: string | null; + + @Column({ type: 'varchar', length: 32, nullable: true, name: 'device_type' }) + deviceType: string | null; + + // Información del dispositivo + @Column({ type: 'text', nullable: true, name: 'user_agent' }) + userAgent: string | null; + + @Column({ type: 'varchar', length: 64, nullable: true, name: 'browser_name' }) + browserName: string | null; + + @Column({ + type: 'varchar', + length: 32, + nullable: true, + name: 'browser_version', + }) + browserVersion: string | null; + + @Column({ type: 'varchar', length: 64, nullable: true, name: 'os_name' }) + osName: string | null; + + @Column({ type: 'varchar', length: 32, nullable: true, name: 'os_version' }) + osVersion: string | null; + + // Ubicación del registro + @Column({ type: 'inet', nullable: false, name: 'registered_ip' }) + registeredIp: string; + + @Column({ type: 'jsonb', nullable: true, name: 'registered_location' }) + registeredLocation: Record | null; + + // Estado de confianza + @Column({ type: 'boolean', default: true, nullable: false, name: 'is_active' }) + isActive: boolean; + + @Column({ + type: 'enum', + enum: TrustLevel, + default: TrustLevel.STANDARD, + nullable: false, + name: 'trust_level', + }) + trustLevel: TrustLevel; + + @Column({ type: 'timestamptz', nullable: true, name: 'trust_expires_at' }) + trustExpiresAt: Date | null; + + // Uso + @Column({ type: 'timestamptz', nullable: false, name: 'last_used_at' }) + lastUsedAt: Date; + + @Column({ type: 'inet', nullable: true, name: 'last_used_ip' }) + lastUsedIp: string | null; + + @Column({ type: 'integer', default: 1, nullable: false, name: 'use_count' }) + useCount: number; + + // Relaciones + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ type: 'timestamptz', nullable: true, name: 'revoked_at' }) + revokedAt: Date | null; + + @Column({ type: 'varchar', length: 128, nullable: true, name: 'revoked_reason' }) + revokedReason: string | null; +} diff --git a/src/modules/auth/entities/user.entity.ts b/src/modules/auth/entities/user.entity.ts new file mode 100644 index 0000000..cabb098 --- /dev/null +++ b/src/modules/auth/entities/user.entity.ts @@ -0,0 +1,141 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + ManyToMany, + JoinColumn, + JoinTable, + OneToMany, +} from 'typeorm'; +import { Tenant } from './tenant.entity.js'; +import { Role } from './role.entity.js'; +import { Company } from './company.entity.js'; +import { Session } from './session.entity.js'; +import { PasswordReset } from './password-reset.entity.js'; + +export enum UserStatus { + ACTIVE = 'active', + INACTIVE = 'inactive', + SUSPENDED = 'suspended', + PENDING_VERIFICATION = 'pending_verification', +} + +@Entity({ schema: 'auth', name: 'users' }) +@Index('idx_users_tenant_id', ['tenantId']) +@Index('idx_users_email', ['email']) +@Index('idx_users_status', ['status'], { where: 'deleted_at IS NULL' }) +@Index('idx_users_email_tenant', ['tenantId', 'email']) +@Index('idx_users_created_at', ['createdAt']) +export class User { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + email: string; + + @Column({ type: 'varchar', length: 255, nullable: false, name: 'password_hash' }) + passwordHash: string; + + @Column({ type: 'varchar', length: 255, nullable: false, name: 'full_name' }) + fullName: string; + + @Column({ type: 'varchar', length: 500, nullable: true, name: 'avatar_url' }) + avatarUrl: string | null; + + @Column({ + type: 'enum', + enum: UserStatus, + default: UserStatus.ACTIVE, + nullable: false, + }) + status: UserStatus; + + @Column({ type: 'boolean', default: false, nullable: false, name: 'is_superuser' }) + isSuperuser: boolean; + + @Column({ + type: 'timestamp', + nullable: true, + name: 'email_verified_at', + }) + emailVerifiedAt: Date | null; + + @Column({ type: 'timestamp', nullable: true, name: 'last_login_at' }) + lastLoginAt: Date | null; + + @Column({ type: 'inet', nullable: true, name: 'last_login_ip' }) + lastLoginIp: string | null; + + @Column({ type: 'integer', default: 0, name: 'login_count' }) + loginCount: number; + + @Column({ type: 'varchar', length: 10, default: 'es' }) + language: string; + + @Column({ type: 'varchar', length: 50, default: 'America/Mexico_City' }) + timezone: string; + + @Column({ type: 'jsonb', default: {} }) + settings: Record; + + // Relaciones + @ManyToOne(() => Tenant, (tenant) => tenant.users, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToMany(() => Role, (role) => role.users) + @JoinTable({ + name: 'user_roles', + schema: 'auth', + joinColumn: { name: 'user_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'role_id', referencedColumnName: 'id' }, + }) + roles: Role[]; + + @ManyToMany(() => Company, (company) => company.users) + @JoinTable({ + name: 'user_companies', + schema: 'auth', + joinColumn: { name: 'user_id', referencedColumnName: 'id' }, + inverseJoinColumn: { name: 'company_id', referencedColumnName: 'id' }, + }) + companies: Company[]; + + @OneToMany(() => Session, (session) => session.user) + sessions: Session[]; + + @OneToMany(() => PasswordReset, (passwordReset) => passwordReset.user) + passwordResets: PasswordReset[]; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + deletedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) + deletedBy: string | null; +} diff --git a/src/modules/auth/entities/verification-code.entity.ts b/src/modules/auth/entities/verification-code.entity.ts new file mode 100644 index 0000000..e71668e --- /dev/null +++ b/src/modules/auth/entities/verification-code.entity.ts @@ -0,0 +1,90 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { User } from './user.entity.js'; +import { Session } from './session.entity.js'; + +export enum CodeType { + TOTP_SETUP = 'totp_setup', + SMS = 'sms', + EMAIL = 'email', + BACKUP = 'backup', +} + +@Entity({ schema: 'auth', name: 'verification_codes' }) +@Index('idx_verification_codes_user', ['userId', 'codeType'], { + where: 'used_at IS NULL', +}) +@Index('idx_verification_codes_expires', ['expiresAt'], { + where: 'used_at IS NULL', +}) +export class VerificationCode { + @PrimaryGeneratedColumn('uuid') + id: string; + + // Relaciones + @Column({ type: 'uuid', nullable: false, name: 'user_id' }) + userId: string; + + @Column({ type: 'uuid', nullable: true, name: 'session_id' }) + sessionId: string | null; + + // Tipo de código + @Column({ + type: 'enum', + enum: CodeType, + nullable: false, + name: 'code_type', + }) + codeType: CodeType; + + // Código (hash SHA-256) + @Column({ type: 'varchar', length: 64, nullable: false, name: 'code_hash' }) + codeHash: string; + + @Column({ type: 'integer', default: 6, nullable: false, name: 'code_length' }) + codeLength: number; + + // Destino (para SMS/Email) + @Column({ type: 'varchar', length: 256, nullable: true }) + destination: string | null; + + // Intentos + @Column({ type: 'integer', default: 0, nullable: false }) + attempts: number; + + @Column({ type: 'integer', default: 5, nullable: false, name: 'max_attempts' }) + maxAttempts: number; + + // Validez + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ type: 'timestamptz', nullable: false, name: 'expires_at' }) + expiresAt: Date; + + @Column({ type: 'timestamptz', nullable: true, name: 'used_at' }) + usedAt: Date | null; + + // Metadata + @Column({ type: 'inet', nullable: true, name: 'ip_address' }) + ipAddress: string | null; + + @Column({ type: 'text', nullable: true, name: 'user_agent' }) + userAgent: string | null; + + // Relaciones + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @ManyToOne(() => Session, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'session_id' }) + session: Session | null; +} diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts new file mode 100644 index 0000000..2afcd75 --- /dev/null +++ b/src/modules/auth/index.ts @@ -0,0 +1,8 @@ +export * from './auth.service.js'; +export * from './auth.controller.js'; +export { default as authRoutes } from './auth.routes.js'; + +// API Keys +export * from './apiKeys.service.js'; +export * from './apiKeys.controller.js'; +export { default as apiKeysRoutes } from './apiKeys.routes.js'; diff --git a/src/modules/auth/services/token.service.ts b/src/modules/auth/services/token.service.ts new file mode 100644 index 0000000..ee671ba --- /dev/null +++ b/src/modules/auth/services/token.service.ts @@ -0,0 +1,456 @@ +import jwt, { SignOptions } from 'jsonwebtoken'; +import { v4 as uuidv4 } from 'uuid'; +import { Repository } from 'typeorm'; +import { AppDataSource } from '../../../config/typeorm.js'; +import { config } from '../../../config/index.js'; +import { User, Session, SessionStatus } from '../entities/index.js'; +import { blacklistToken, isTokenBlacklisted } from '../../../config/redis.js'; +import { logger } from '../../../shared/utils/logger.js'; +import { UnauthorizedError } from '../../../shared/types/index.js'; + +// ===== Interfaces ===== + +/** + * JWT Payload structure for access and refresh tokens + */ +export interface JwtPayload { + sub: string; // User ID + tid: string; // Tenant ID + email: string; + roles: string[]; + jti: string; // JWT ID único + iat: number; + exp: number; +} + +/** + * Token pair returned after authentication + */ +export interface TokenPair { + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: Date; + refreshTokenExpiresAt: Date; + sessionId: string; +} + +/** + * Request metadata for session tracking + */ +export interface RequestMetadata { + ipAddress: string; + userAgent: string; +} + +// ===== TokenService Class ===== + +/** + * Service for managing JWT tokens with blacklist support via Redis + * and session tracking via TypeORM + */ +class TokenService { + private sessionRepository: Repository; + + // Configuration constants + private readonly ACCESS_TOKEN_EXPIRY = '15m'; + private readonly REFRESH_TOKEN_EXPIRY = '7d'; + private readonly ALGORITHM = 'HS256' as const; + + constructor() { + this.sessionRepository = AppDataSource.getRepository(Session); + } + + /** + * Generates a new token pair (access + refresh) and creates a session + * @param user - User entity with roles loaded + * @param metadata - Request metadata (IP, user agent) + * @returns Promise - Access and refresh tokens with expiration dates + */ + async generateTokenPair(user: User, metadata: RequestMetadata): Promise { + try { + logger.debug('Generating token pair', { userId: user.id, tenantId: user.tenantId }); + + // Extract role codes from user roles + const roles = user.roles ? user.roles.map(role => role.code) : []; + + // Calculate expiration dates + const accessTokenExpiresAt = this.calculateExpiration(this.ACCESS_TOKEN_EXPIRY); + const refreshTokenExpiresAt = this.calculateExpiration(this.REFRESH_TOKEN_EXPIRY); + + // Generate unique JWT IDs + const accessJti = this.generateJti(); + const refreshJti = this.generateJti(); + + // Generate access token + const accessToken = this.generateToken({ + sub: user.id, + tid: user.tenantId, + email: user.email, + roles, + jti: accessJti, + }, this.ACCESS_TOKEN_EXPIRY); + + // Generate refresh token + const refreshToken = this.generateToken({ + sub: user.id, + tid: user.tenantId, + email: user.email, + roles, + jti: refreshJti, + }, this.REFRESH_TOKEN_EXPIRY); + + // Create session record in database + const session = this.sessionRepository.create({ + userId: user.id, + token: accessJti, // Store JTI instead of full token + refreshToken: refreshJti, // Store JTI instead of full token + status: SessionStatus.ACTIVE, + expiresAt: accessTokenExpiresAt, + refreshExpiresAt: refreshTokenExpiresAt, + ipAddress: metadata.ipAddress, + userAgent: metadata.userAgent, + }); + + await this.sessionRepository.save(session); + + logger.info('Token pair generated successfully', { + userId: user.id, + sessionId: session.id, + tenantId: user.tenantId, + }); + + return { + accessToken, + refreshToken, + accessTokenExpiresAt, + refreshTokenExpiresAt, + sessionId: session.id, + }; + } catch (error) { + logger.error('Error generating token pair', { + error: (error as Error).message, + userId: user.id, + }); + throw error; + } + } + + /** + * Refreshes an access token using a valid refresh token + * Implements token replay detection for enhanced security + * @param refreshToken - Valid refresh token + * @param metadata - Request metadata (IP, user agent) + * @returns Promise - New access and refresh tokens + * @throws UnauthorizedError if token is invalid or replay detected + */ + async refreshTokens(refreshToken: string, metadata: RequestMetadata): Promise { + try { + logger.debug('Refreshing tokens'); + + // Verify refresh token + const payload = this.verifyRefreshToken(refreshToken); + + // Find active session with this refresh token JTI + const session = await this.sessionRepository.findOne({ + where: { + refreshToken: payload.jti, + status: SessionStatus.ACTIVE, + }, + relations: ['user', 'user.roles'], + }); + + if (!session) { + logger.warn('Refresh token not found or session inactive', { + jti: payload.jti, + }); + throw new UnauthorizedError('Refresh token inválido o expirado'); + } + + // Check if session has already been used (token replay detection) + if (session.revokedAt !== null) { + logger.error('TOKEN REPLAY DETECTED - Session was already used', { + sessionId: session.id, + userId: session.userId, + jti: payload.jti, + }); + + // SECURITY: Revoke ALL user sessions on replay detection + const revokedCount = await this.revokeAllUserSessions( + session.userId, + 'Token replay detected' + ); + + logger.error('All user sessions revoked due to token replay', { + userId: session.userId, + revokedCount, + }); + + throw new UnauthorizedError('Replay de token detectado. Todas las sesiones han sido revocadas por seguridad.'); + } + + // Verify session hasn't expired + if (session.refreshExpiresAt && new Date() > session.refreshExpiresAt) { + logger.warn('Refresh token expired', { + sessionId: session.id, + expiredAt: session.refreshExpiresAt, + }); + + await this.revokeSession(session.id, 'Token expired'); + throw new UnauthorizedError('Refresh token expirado'); + } + + // Mark current session as used (revoke it) + session.status = SessionStatus.REVOKED; + session.revokedAt = new Date(); + session.revokedReason = 'Used for refresh'; + await this.sessionRepository.save(session); + + // Generate new token pair + const newTokenPair = await this.generateTokenPair(session.user, metadata); + + logger.info('Tokens refreshed successfully', { + userId: session.userId, + oldSessionId: session.id, + newSessionId: newTokenPair.sessionId, + }); + + return newTokenPair; + } catch (error) { + logger.error('Error refreshing tokens', { + error: (error as Error).message, + }); + throw error; + } + } + + /** + * Revokes a session and blacklists its access token + * @param sessionId - Session ID to revoke + * @param reason - Reason for revocation + */ + async revokeSession(sessionId: string, reason: string): Promise { + try { + logger.debug('Revoking session', { sessionId, reason }); + + const session = await this.sessionRepository.findOne({ + where: { id: sessionId }, + }); + + if (!session) { + logger.warn('Session not found for revocation', { sessionId }); + return; + } + + // Mark session as revoked + session.status = SessionStatus.REVOKED; + session.revokedAt = new Date(); + session.revokedReason = reason; + await this.sessionRepository.save(session); + + // Blacklist the access token (JTI) in Redis + const remainingTTL = this.calculateRemainingTTL(session.expiresAt); + if (remainingTTL > 0) { + await this.blacklistAccessToken(session.token, remainingTTL); + } + + logger.info('Session revoked successfully', { sessionId, reason }); + } catch (error) { + logger.error('Error revoking session', { + error: (error as Error).message, + sessionId, + }); + throw error; + } + } + + /** + * Revokes all active sessions for a user + * Used for security events like password change or token replay detection + * @param userId - User ID whose sessions to revoke + * @param reason - Reason for revocation + * @returns Promise - Number of sessions revoked + */ + async revokeAllUserSessions(userId: string, reason: string): Promise { + try { + logger.debug('Revoking all user sessions', { userId, reason }); + + const sessions = await this.sessionRepository.find({ + where: { + userId, + status: SessionStatus.ACTIVE, + }, + }); + + if (sessions.length === 0) { + logger.debug('No active sessions found for user', { userId }); + return 0; + } + + // Revoke each session + for (const session of sessions) { + session.status = SessionStatus.REVOKED; + session.revokedAt = new Date(); + session.revokedReason = reason; + + // Blacklist access token + const remainingTTL = this.calculateRemainingTTL(session.expiresAt); + if (remainingTTL > 0) { + await this.blacklistAccessToken(session.token, remainingTTL); + } + } + + await this.sessionRepository.save(sessions); + + logger.info('All user sessions revoked', { + userId, + count: sessions.length, + reason, + }); + + return sessions.length; + } catch (error) { + logger.error('Error revoking all user sessions', { + error: (error as Error).message, + userId, + }); + throw error; + } + } + + /** + * Adds an access token to the Redis blacklist + * @param jti - JWT ID to blacklist + * @param expiresIn - TTL in seconds + */ + async blacklistAccessToken(jti: string, expiresIn: number): Promise { + try { + await blacklistToken(jti, expiresIn); + logger.debug('Access token blacklisted', { jti, expiresIn }); + } catch (error) { + logger.error('Error blacklisting access token', { + error: (error as Error).message, + jti, + }); + // Don't throw - blacklist is optional (Redis might be unavailable) + } + } + + /** + * Checks if an access token is blacklisted + * @param jti - JWT ID to check + * @returns Promise - true if blacklisted + */ + async isAccessTokenBlacklisted(jti: string): Promise { + try { + return await isTokenBlacklisted(jti); + } catch (error) { + logger.error('Error checking token blacklist', { + error: (error as Error).message, + jti, + }); + // Return false on error - fail open + return false; + } + } + + // ===== Private Helper Methods ===== + + /** + * Generates a JWT token with the specified payload and expiry + * @param payload - Token payload (without iat/exp) + * @param expiresIn - Expiration time string (e.g., '15m', '7d') + * @returns string - Signed JWT token + */ + private generateToken(payload: Omit, expiresIn: string): string { + return jwt.sign(payload, config.jwt.secret, { + expiresIn: expiresIn as jwt.SignOptions['expiresIn'], + algorithm: this.ALGORITHM, + } as SignOptions); + } + + /** + * Verifies an access token and returns its payload + * @param token - JWT access token + * @returns JwtPayload - Decoded payload + * @throws UnauthorizedError if token is invalid + */ + private verifyAccessToken(token: string): JwtPayload { + try { + return jwt.verify(token, config.jwt.secret, { + algorithms: [this.ALGORITHM], + }) as JwtPayload; + } catch (error) { + logger.warn('Invalid access token', { + error: (error as Error).message, + }); + throw new UnauthorizedError('Access token inválido o expirado'); + } + } + + /** + * Verifies a refresh token and returns its payload + * @param token - JWT refresh token + * @returns JwtPayload - Decoded payload + * @throws UnauthorizedError if token is invalid + */ + private verifyRefreshToken(token: string): JwtPayload { + try { + return jwt.verify(token, config.jwt.secret, { + algorithms: [this.ALGORITHM], + }) as JwtPayload; + } catch (error) { + logger.warn('Invalid refresh token', { + error: (error as Error).message, + }); + throw new UnauthorizedError('Refresh token inválido o expirado'); + } + } + + /** + * Generates a unique JWT ID (JTI) using UUID v4 + * @returns string - Unique identifier + */ + private generateJti(): string { + return uuidv4(); + } + + /** + * Calculates expiration date from a time string + * @param expiresIn - Time string (e.g., '15m', '7d') + * @returns Date - Expiration date + */ + private calculateExpiration(expiresIn: string): Date { + const unit = expiresIn.slice(-1); + const value = parseInt(expiresIn.slice(0, -1), 10); + + const now = new Date(); + + switch (unit) { + case 's': + return new Date(now.getTime() + value * 1000); + case 'm': + return new Date(now.getTime() + value * 60 * 1000); + case 'h': + return new Date(now.getTime() + value * 60 * 60 * 1000); + case 'd': + return new Date(now.getTime() + value * 24 * 60 * 60 * 1000); + default: + throw new Error(`Invalid time unit: ${unit}`); + } + } + + /** + * Calculates remaining TTL in seconds for a given expiration date + * @param expiresAt - Expiration date + * @returns number - Remaining seconds (0 if already expired) + */ + private calculateRemainingTTL(expiresAt: Date): number { + const now = new Date(); + const remainingMs = expiresAt.getTime() - now.getTime(); + return Math.max(0, Math.floor(remainingMs / 1000)); + } +} + +// ===== Export Singleton Instance ===== + +export const tokenService = new TokenService(); diff --git a/src/modules/companies/companies.controller.ts b/src/modules/companies/companies.controller.ts new file mode 100644 index 0000000..e59bc40 --- /dev/null +++ b/src/modules/companies/companies.controller.ts @@ -0,0 +1,241 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { companiesService, CreateCompanyDto, UpdateCompanyDto, CompanyFilters } from './companies.service.js'; +import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js'; + +// Validation schemas (accept both snake_case and camelCase from frontend) +const createCompanySchema = z.object({ + name: z.string().min(1, 'El nombre es requerido').max(255), + legal_name: z.string().max(255).optional(), + legalName: z.string().max(255).optional(), + tax_id: z.string().max(50).optional(), + taxId: z.string().max(50).optional(), + currency_id: z.string().uuid().optional(), + currencyId: z.string().uuid().optional(), + parent_company_id: z.string().uuid().optional(), + parentCompanyId: z.string().uuid().optional(), + settings: z.record(z.any()).optional(), +}); + +const updateCompanySchema = z.object({ + name: z.string().min(1).max(255).optional(), + legal_name: z.string().max(255).optional().nullable(), + legalName: z.string().max(255).optional().nullable(), + tax_id: z.string().max(50).optional().nullable(), + taxId: z.string().max(50).optional().nullable(), + currency_id: z.string().uuid().optional().nullable(), + currencyId: z.string().uuid().optional().nullable(), + parent_company_id: z.string().uuid().optional().nullable(), + parentCompanyId: z.string().uuid().optional().nullable(), + settings: z.record(z.any()).optional(), +}); + +const querySchema = z.object({ + search: z.string().optional(), + parent_company_id: z.string().uuid().optional(), + parentCompanyId: z.string().uuid().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +class CompaniesController { + async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = querySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const tenantId = req.user!.tenantId; + const filters: CompanyFilters = { + search: queryResult.data.search, + parentCompanyId: queryResult.data.parentCompanyId || queryResult.data.parent_company_id, + page: queryResult.data.page, + limit: queryResult.data.limit, + }; + + const result = await companiesService.findAll(tenantId, filters); + + const response: ApiResponse = { + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page || 1, + limit: filters.limit || 20, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async findById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const tenantId = req.user!.tenantId; + const company = await companiesService.findById(id, tenantId); + + const response: ApiResponse = { + success: true, + data: company, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createCompanySchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de empresa inválidos', parseResult.error.errors); + } + + const data = parseResult.data; + const tenantId = req.user!.tenantId; + const userId = req.user!.userId; + + // Transform to camelCase DTO + const dto: CreateCompanyDto = { + name: data.name, + legalName: data.legalName || data.legal_name, + taxId: data.taxId || data.tax_id, + currencyId: data.currencyId || data.currency_id, + parentCompanyId: data.parentCompanyId || data.parent_company_id, + settings: data.settings, + }; + + const company = await companiesService.create(dto, tenantId, userId); + + const response: ApiResponse = { + success: true, + data: company, + message: 'Empresa creada exitosamente', + }; + + res.status(201).json(response); + } catch (error) { + next(error); + } + } + + async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const parseResult = updateCompanySchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de empresa inválidos', parseResult.error.errors); + } + + const data = parseResult.data; + const tenantId = req.user!.tenantId; + const userId = req.user!.userId; + + // Transform to camelCase DTO + const dto: UpdateCompanyDto = {}; + if (data.name !== undefined) dto.name = data.name; + if (data.legalName !== undefined || data.legal_name !== undefined) { + dto.legalName = data.legalName ?? data.legal_name; + } + if (data.taxId !== undefined || data.tax_id !== undefined) { + dto.taxId = data.taxId ?? data.tax_id; + } + if (data.currencyId !== undefined || data.currency_id !== undefined) { + dto.currencyId = data.currencyId ?? data.currency_id; + } + if (data.parentCompanyId !== undefined || data.parent_company_id !== undefined) { + dto.parentCompanyId = data.parentCompanyId ?? data.parent_company_id; + } + if (data.settings !== undefined) dto.settings = data.settings; + + const company = await companiesService.update(id, dto, tenantId, userId); + + const response: ApiResponse = { + success: true, + data: company, + message: 'Empresa actualizada exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const tenantId = req.user!.tenantId; + const userId = req.user!.userId; + + await companiesService.delete(id, tenantId, userId); + + const response: ApiResponse = { + success: true, + message: 'Empresa eliminada exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async getUsers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const tenantId = req.user!.tenantId; + const users = await companiesService.getUsers(id, tenantId); + + const response: ApiResponse = { + success: true, + data: users, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async getSubsidiaries(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const tenantId = req.user!.tenantId; + const subsidiaries = await companiesService.getSubsidiaries(id, tenantId); + + const response: ApiResponse = { + success: true, + data: subsidiaries, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async getHierarchy(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const hierarchy = await companiesService.getHierarchy(tenantId); + + const response: ApiResponse = { + success: true, + data: hierarchy, + }; + + res.json(response); + } catch (error) { + next(error); + } + } +} + +export const companiesController = new CompaniesController(); diff --git a/src/modules/companies/companies.routes.ts b/src/modules/companies/companies.routes.ts new file mode 100644 index 0000000..e18bb78 --- /dev/null +++ b/src/modules/companies/companies.routes.ts @@ -0,0 +1,50 @@ +import { Router } from 'express'; +import { companiesController } from './companies.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// List companies (admin, manager) +router.get('/', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + companiesController.findAll(req, res, next) +); + +// Get company hierarchy tree (must be before /:id to avoid conflict) +router.get('/hierarchy/tree', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + companiesController.getHierarchy(req, res, next) +); + +// Get company by ID +router.get('/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + companiesController.findById(req, res, next) +); + +// Create company (admin only) +router.post('/', requireRoles('admin', 'super_admin'), (req, res, next) => + companiesController.create(req, res, next) +); + +// Update company (admin only) +router.put('/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + companiesController.update(req, res, next) +); + +// Delete company (admin only) +router.delete('/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + companiesController.delete(req, res, next) +); + +// Get users assigned to company +router.get('/:id/users', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + companiesController.getUsers(req, res, next) +); + +// Get subsidiaries (child companies) +router.get('/:id/subsidiaries', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + companiesController.getSubsidiaries(req, res, next) +); + +export default router; diff --git a/src/modules/companies/companies.service.ts b/src/modules/companies/companies.service.ts new file mode 100644 index 0000000..f42e47e --- /dev/null +++ b/src/modules/companies/companies.service.ts @@ -0,0 +1,472 @@ +import { Repository, IsNull } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { Company } from '../auth/entities/index.js'; +import { NotFoundError, ValidationError, ForbiddenError } from '../../shared/types/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ===== Interfaces ===== + +export interface CreateCompanyDto { + name: string; + legalName?: string; + taxId?: string; + currencyId?: string; + parentCompanyId?: string; + settings?: Record; +} + +export interface UpdateCompanyDto { + name?: string; + legalName?: string | null; + taxId?: string | null; + currencyId?: string | null; + parentCompanyId?: string | null; + settings?: Record; +} + +export interface CompanyFilters { + search?: string; + parentCompanyId?: string; + page?: number; + limit?: number; +} + +export interface CompanyWithRelations extends Company { + currencyCode?: string; + parentCompanyName?: string; +} + +// ===== CompaniesService Class ===== + +class CompaniesService { + private companyRepository: Repository; + + constructor() { + this.companyRepository = AppDataSource.getRepository(Company); + } + + /** + * Get all companies for a tenant with filters and pagination + */ + async findAll( + tenantId: string, + filters: CompanyFilters = {} + ): Promise<{ data: CompanyWithRelations[]; total: number }> { + try { + const { search, parentCompanyId, page = 1, limit = 20 } = filters; + const skip = (page - 1) * limit; + + const queryBuilder = this.companyRepository + .createQueryBuilder('company') + .leftJoin('company.parentCompany', 'parentCompany') + .addSelect(['parentCompany.name']) + .where('company.tenantId = :tenantId', { tenantId }) + .andWhere('company.deletedAt IS NULL'); + + // Apply search filter + if (search) { + queryBuilder.andWhere( + '(company.name ILIKE :search OR company.legalName ILIKE :search OR company.taxId ILIKE :search)', + { search: `%${search}%` } + ); + } + + // Filter by parent company + if (parentCompanyId) { + queryBuilder.andWhere('company.parentCompanyId = :parentCompanyId', { parentCompanyId }); + } + + // Get total count + const total = await queryBuilder.getCount(); + + // Get paginated results + const companies = await queryBuilder + .orderBy('company.name', 'ASC') + .skip(skip) + .take(limit) + .getMany(); + + // Map to include relation names + const data: CompanyWithRelations[] = companies.map(company => ({ + ...company, + parentCompanyName: company.parentCompany?.name, + })); + + logger.debug('Companies retrieved', { tenantId, count: data.length, total }); + + return { data, total }; + } catch (error) { + logger.error('Error retrieving companies', { + error: (error as Error).message, + tenantId, + }); + throw error; + } + } + + /** + * Get company by ID + */ + async findById(id: string, tenantId: string): Promise { + try { + const company = await this.companyRepository + .createQueryBuilder('company') + .leftJoin('company.parentCompany', 'parentCompany') + .addSelect(['parentCompany.name']) + .where('company.id = :id', { id }) + .andWhere('company.tenantId = :tenantId', { tenantId }) + .andWhere('company.deletedAt IS NULL') + .getOne(); + + if (!company) { + throw new NotFoundError('Empresa no encontrada'); + } + + return { + ...company, + parentCompanyName: company.parentCompany?.name, + }; + } catch (error) { + logger.error('Error finding company', { + error: (error as Error).message, + id, + tenantId, + }); + throw error; + } + } + + /** + * Create a new company + */ + async create( + dto: CreateCompanyDto, + tenantId: string, + userId: string + ): Promise { + try { + // Validate unique tax_id within tenant + if (dto.taxId) { + const existing = await this.companyRepository.findOne({ + where: { + tenantId, + taxId: dto.taxId, + deletedAt: IsNull(), + }, + }); + + if (existing) { + throw new ValidationError('Ya existe una empresa con este RFC'); + } + } + + // Validate parent company exists + if (dto.parentCompanyId) { + const parent = await this.companyRepository.findOne({ + where: { + id: dto.parentCompanyId, + tenantId, + deletedAt: IsNull(), + }, + }); + + if (!parent) { + throw new NotFoundError('Empresa matriz no encontrada'); + } + } + + // Create company + const company = this.companyRepository.create({ + tenantId, + name: dto.name, + legalName: dto.legalName || null, + taxId: dto.taxId || null, + currencyId: dto.currencyId || null, + parentCompanyId: dto.parentCompanyId || null, + settings: dto.settings || {}, + createdBy: userId, + }); + + await this.companyRepository.save(company); + + logger.info('Company created', { + companyId: company.id, + tenantId, + name: company.name, + createdBy: userId, + }); + + return company; + } catch (error) { + logger.error('Error creating company', { + error: (error as Error).message, + tenantId, + dto, + }); + throw error; + } + } + + /** + * Update a company + */ + async update( + id: string, + dto: UpdateCompanyDto, + tenantId: string, + userId: string + ): Promise { + try { + const existing = await this.findById(id, tenantId); + + // Validate unique tax_id if changing + if (dto.taxId !== undefined && dto.taxId !== existing.taxId) { + if (dto.taxId) { + const duplicate = await this.companyRepository.findOne({ + where: { + tenantId, + taxId: dto.taxId, + deletedAt: IsNull(), + }, + }); + + if (duplicate && duplicate.id !== id) { + throw new ValidationError('Ya existe una empresa con este RFC'); + } + } + } + + // Validate parent company (prevent self-reference and cycles) + if (dto.parentCompanyId !== undefined && dto.parentCompanyId) { + if (dto.parentCompanyId === id) { + throw new ValidationError('Una empresa no puede ser su propia matriz'); + } + + const parent = await this.companyRepository.findOne({ + where: { + id: dto.parentCompanyId, + tenantId, + deletedAt: IsNull(), + }, + }); + + if (!parent) { + throw new NotFoundError('Empresa matriz no encontrada'); + } + + // Check for circular reference + if (await this.wouldCreateCycle(id, dto.parentCompanyId, tenantId)) { + throw new ValidationError('La asignación crearía una referencia circular'); + } + } + + // Update allowed fields + if (dto.name !== undefined) existing.name = dto.name; + if (dto.legalName !== undefined) existing.legalName = dto.legalName; + if (dto.taxId !== undefined) existing.taxId = dto.taxId; + if (dto.currencyId !== undefined) existing.currencyId = dto.currencyId; + if (dto.parentCompanyId !== undefined) existing.parentCompanyId = dto.parentCompanyId; + if (dto.settings !== undefined) { + existing.settings = { ...existing.settings, ...dto.settings }; + } + + existing.updatedBy = userId; + existing.updatedAt = new Date(); + + await this.companyRepository.save(existing); + + logger.info('Company updated', { + companyId: id, + tenantId, + updatedBy: userId, + }); + + return await this.findById(id, tenantId); + } catch (error) { + logger.error('Error updating company', { + error: (error as Error).message, + id, + tenantId, + }); + throw error; + } + } + + /** + * Soft delete a company + */ + async delete(id: string, tenantId: string, userId: string): Promise { + try { + await this.findById(id, tenantId); + + // Check if company has child companies + const childrenCount = await this.companyRepository.count({ + where: { + parentCompanyId: id, + tenantId, + deletedAt: IsNull(), + }, + }); + + if (childrenCount > 0) { + throw new ForbiddenError( + 'No se puede eliminar una empresa que tiene empresas subsidiarias' + ); + } + + // Soft delete + await this.companyRepository.update( + { id, tenantId }, + { + deletedAt: new Date(), + deletedBy: userId, + } + ); + + logger.info('Company deleted', { + companyId: id, + tenantId, + deletedBy: userId, + }); + } catch (error) { + logger.error('Error deleting company', { + error: (error as Error).message, + id, + tenantId, + }); + throw error; + } + } + + /** + * Get users assigned to a company + */ + async getUsers(companyId: string, tenantId: string): Promise { + try { + await this.findById(companyId, tenantId); + + // Using raw query for user_companies junction table + const users = await this.companyRepository.query( + `SELECT u.id, u.email, u.full_name, u.status, uc.is_default, uc.assigned_at + FROM auth.users u + INNER JOIN auth.user_companies uc ON u.id = uc.user_id + WHERE uc.company_id = $1 AND u.tenant_id = $2 AND u.deleted_at IS NULL + ORDER BY u.full_name`, + [companyId, tenantId] + ); + + return users; + } catch (error) { + logger.error('Error getting company users', { + error: (error as Error).message, + companyId, + tenantId, + }); + throw error; + } + } + + /** + * Get child companies (subsidiaries) + */ + async getSubsidiaries(companyId: string, tenantId: string): Promise { + try { + await this.findById(companyId, tenantId); + + return await this.companyRepository.find({ + where: { + parentCompanyId: companyId, + tenantId, + deletedAt: IsNull(), + }, + order: { name: 'ASC' }, + }); + } catch (error) { + logger.error('Error getting subsidiaries', { + error: (error as Error).message, + companyId, + tenantId, + }); + throw error; + } + } + + /** + * Get full company hierarchy (tree structure) + */ + async getHierarchy(tenantId: string): Promise { + try { + // Get all companies + const companies = await this.companyRepository.find({ + where: { tenantId, deletedAt: IsNull() }, + order: { name: 'ASC' }, + }); + + // Build tree structure + const companyMap = new Map(); + const roots: any[] = []; + + // First pass: create map + for (const company of companies) { + companyMap.set(company.id, { + ...company, + children: [], + }); + } + + // Second pass: build tree + for (const company of companies) { + const node = companyMap.get(company.id); + if (company.parentCompanyId && companyMap.has(company.parentCompanyId)) { + companyMap.get(company.parentCompanyId).children.push(node); + } else { + roots.push(node); + } + } + + return roots; + } catch (error) { + logger.error('Error getting company hierarchy', { + error: (error as Error).message, + tenantId, + }); + throw error; + } + } + + /** + * Check if assigning a parent would create a circular reference + */ + private async wouldCreateCycle( + companyId: string, + newParentId: string, + tenantId: string + ): Promise { + let currentId: string | null = newParentId; + const visited = new Set(); + + while (currentId) { + if (visited.has(currentId)) { + return true; // Found a cycle + } + if (currentId === companyId) { + return true; // Would create a cycle + } + + visited.add(currentId); + + const parent = await this.companyRepository.findOne({ + where: { id: currentId, tenantId, deletedAt: IsNull() }, + select: ['parentCompanyId'], + }); + + currentId = parent?.parentCompanyId || null; + } + + return false; + } +} + +// ===== Export Singleton Instance ===== + +export const companiesService = new CompaniesService(); diff --git a/src/modules/companies/index.ts b/src/modules/companies/index.ts new file mode 100644 index 0000000..fbf5e5b --- /dev/null +++ b/src/modules/companies/index.ts @@ -0,0 +1,3 @@ +export * from './companies.service.js'; +export * from './companies.controller.js'; +export { default as companiesRoutes } from './companies.routes.js'; diff --git a/src/modules/core/core.controller.ts b/src/modules/core/core.controller.ts new file mode 100644 index 0000000..79f6c90 --- /dev/null +++ b/src/modules/core/core.controller.ts @@ -0,0 +1,257 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { currenciesService, CreateCurrencyDto, UpdateCurrencyDto } from './currencies.service.js'; +import { countriesService } from './countries.service.js'; +import { uomService, CreateUomDto, UpdateUomDto } from './uom.service.js'; +import { productCategoriesService, CreateProductCategoryDto, UpdateProductCategoryDto } from './product-categories.service.js'; +import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js'; +import { ValidationError } from '../../shared/errors/index.js'; + +// Schemas +const createCurrencySchema = z.object({ + code: z.string().length(3, 'El código debe tener 3 caracteres').toUpperCase(), + name: z.string().min(1, 'El nombre es requerido').max(100), + symbol: z.string().min(1).max(10), + decimal_places: z.number().int().min(0).max(6).optional(), + decimals: z.number().int().min(0).max(6).optional(), // Accept camelCase +}).refine((data) => data.decimal_places !== undefined || data.decimals !== undefined, { + message: 'decimal_places or decimals is required', +}); + +const updateCurrencySchema = z.object({ + name: z.string().min(1).max(100).optional(), + symbol: z.string().min(1).max(10).optional(), + decimal_places: z.number().int().min(0).max(6).optional(), + decimals: z.number().int().min(0).max(6).optional(), // Accept camelCase + active: z.boolean().optional(), +}); + +const createUomSchema = z.object({ + name: z.string().min(1, 'El nombre es requerido').max(100), + code: z.string().min(1).max(20), + category_id: z.string().uuid().optional(), + categoryId: z.string().uuid().optional(), // Accept camelCase + uom_type: z.enum(['reference', 'bigger', 'smaller']).optional(), + uomType: z.enum(['reference', 'bigger', 'smaller']).optional(), // Accept camelCase + ratio: z.number().positive().default(1), +}).refine((data) => data.category_id !== undefined || data.categoryId !== undefined, { + message: 'category_id or categoryId is required', +}); + +const updateUomSchema = z.object({ + name: z.string().min(1).max(100).optional(), + ratio: z.number().positive().optional(), + active: z.boolean().optional(), +}); + +const createCategorySchema = z.object({ + name: z.string().min(1, 'El nombre es requerido').max(100), + code: z.string().min(1).max(50), + parent_id: z.string().uuid().optional(), + parentId: z.string().uuid().optional(), // Accept camelCase +}); + +const updateCategorySchema = z.object({ + name: z.string().min(1).max(100).optional(), + parent_id: z.string().uuid().optional().nullable(), + parentId: z.string().uuid().optional().nullable(), // Accept camelCase + active: z.boolean().optional(), +}); + +class CoreController { + // ========== CURRENCIES ========== + async getCurrencies(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const activeOnly = req.query.active === 'true'; + const currencies = await currenciesService.findAll(activeOnly); + res.json({ success: true, data: currencies }); + } catch (error) { + next(error); + } + } + + async getCurrency(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const currency = await currenciesService.findById(req.params.id); + res.json({ success: true, data: currency }); + } catch (error) { + next(error); + } + } + + async createCurrency(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createCurrencySchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de moneda inválidos', parseResult.error.errors); + } + const dto: CreateCurrencyDto = parseResult.data; + const currency = await currenciesService.create(dto); + res.status(201).json({ success: true, data: currency, message: 'Moneda creada exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateCurrency(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateCurrencySchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de moneda inválidos', parseResult.error.errors); + } + const dto: UpdateCurrencyDto = parseResult.data; + const currency = await currenciesService.update(req.params.id, dto); + res.json({ success: true, data: currency, message: 'Moneda actualizada exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== COUNTRIES ========== + async getCountries(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const countries = await countriesService.findAll(); + res.json({ success: true, data: countries }); + } catch (error) { + next(error); + } + } + + async getCountry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const country = await countriesService.findById(req.params.id); + res.json({ success: true, data: country }); + } catch (error) { + next(error); + } + } + + // ========== UOM CATEGORIES ========== + async getUomCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const activeOnly = req.query.active === 'true'; + const categories = await uomService.findAllCategories(activeOnly); + res.json({ success: true, data: categories }); + } catch (error) { + next(error); + } + } + + async getUomCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const category = await uomService.findCategoryById(req.params.id); + res.json({ success: true, data: category }); + } catch (error) { + next(error); + } + } + + // ========== UOM ========== + async getUoms(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const activeOnly = req.query.active === 'true'; + const categoryId = req.query.category_id as string | undefined; + const uoms = await uomService.findAll(categoryId, activeOnly); + res.json({ success: true, data: uoms }); + } catch (error) { + next(error); + } + } + + async getUom(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const uom = await uomService.findById(req.params.id); + res.json({ success: true, data: uom }); + } catch (error) { + next(error); + } + } + + async createUom(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createUomSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de UdM inválidos', parseResult.error.errors); + } + const dto: CreateUomDto = parseResult.data; + const uom = await uomService.create(dto); + res.status(201).json({ success: true, data: uom, message: 'Unidad de medida creada exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateUom(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateUomSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de UdM inválidos', parseResult.error.errors); + } + const dto: UpdateUomDto = parseResult.data; + const uom = await uomService.update(req.params.id, dto); + res.json({ success: true, data: uom, message: 'Unidad de medida actualizada exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== PRODUCT CATEGORIES ========== + async getProductCategories(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const activeOnly = req.query.active === 'true'; + const parentId = req.query.parent_id as string | undefined; + const categories = await productCategoriesService.findAll(req.tenantId!, parentId, activeOnly); + res.json({ success: true, data: categories }); + } catch (error) { + next(error); + } + } + + async getProductCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const category = await productCategoriesService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: category }); + } catch (error) { + next(error); + } + } + + async createProductCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createCategorySchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de categoría inválidos', parseResult.error.errors); + } + const dto: CreateProductCategoryDto = parseResult.data; + const category = await productCategoriesService.create(dto, req.tenantId!, req.user!.userId); + res.status(201).json({ success: true, data: category, message: 'Categoría creada exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateProductCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateCategorySchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de categoría inválidos', parseResult.error.errors); + } + const dto: UpdateProductCategoryDto = parseResult.data; + const category = await productCategoriesService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + res.json({ success: true, data: category, message: 'Categoría actualizada exitosamente' }); + } catch (error) { + next(error); + } + } + + async deleteProductCategory(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await productCategoriesService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Categoría eliminada exitosamente' }); + } catch (error) { + next(error); + } + } +} + +export const coreController = new CoreController(); diff --git a/src/modules/core/core.routes.ts b/src/modules/core/core.routes.ts new file mode 100644 index 0000000..f353f73 --- /dev/null +++ b/src/modules/core/core.routes.ts @@ -0,0 +1,51 @@ +import { Router } from 'express'; +import { coreController } from './core.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// ========== CURRENCIES ========== +router.get('/currencies', (req, res, next) => coreController.getCurrencies(req, res, next)); +router.get('/currencies/:id', (req, res, next) => coreController.getCurrency(req, res, next)); +router.post('/currencies', requireRoles('admin', 'super_admin'), (req, res, next) => + coreController.createCurrency(req, res, next) +); +router.put('/currencies/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + coreController.updateCurrency(req, res, next) +); + +// ========== COUNTRIES ========== +router.get('/countries', (req, res, next) => coreController.getCountries(req, res, next)); +router.get('/countries/:id', (req, res, next) => coreController.getCountry(req, res, next)); + +// ========== UOM CATEGORIES ========== +router.get('/uom-categories', (req, res, next) => coreController.getUomCategories(req, res, next)); +router.get('/uom-categories/:id', (req, res, next) => coreController.getUomCategory(req, res, next)); + +// ========== UOM ========== +router.get('/uom', (req, res, next) => coreController.getUoms(req, res, next)); +router.get('/uom/:id', (req, res, next) => coreController.getUom(req, res, next)); +router.post('/uom', requireRoles('admin', 'super_admin'), (req, res, next) => + coreController.createUom(req, res, next) +); +router.put('/uom/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + coreController.updateUom(req, res, next) +); + +// ========== PRODUCT CATEGORIES ========== +router.get('/product-categories', (req, res, next) => coreController.getProductCategories(req, res, next)); +router.get('/product-categories/:id', (req, res, next) => coreController.getProductCategory(req, res, next)); +router.post('/product-categories', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + coreController.createProductCategory(req, res, next) +); +router.put('/product-categories/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + coreController.updateProductCategory(req, res, next) +); +router.delete('/product-categories/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + coreController.deleteProductCategory(req, res, next) +); + +export default router; diff --git a/src/modules/core/countries.service.ts b/src/modules/core/countries.service.ts new file mode 100644 index 0000000..943a37c --- /dev/null +++ b/src/modules/core/countries.service.ts @@ -0,0 +1,45 @@ +import { Repository } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { Country } from './entities/country.entity.js'; +import { NotFoundError } from '../../shared/errors/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +class CountriesService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(Country); + } + + async findAll(): Promise { + logger.debug('Finding all countries'); + + return this.repository.find({ + order: { name: 'ASC' }, + }); + } + + async findById(id: string): Promise { + logger.debug('Finding country by id', { id }); + + const country = await this.repository.findOne({ + where: { id }, + }); + + if (!country) { + throw new NotFoundError('País no encontrado'); + } + + return country; + } + + async findByCode(code: string): Promise { + logger.debug('Finding country by code', { code }); + + return this.repository.findOne({ + where: { code: code.toUpperCase() }, + }); + } +} + +export const countriesService = new CountriesService(); diff --git a/src/modules/core/currencies.service.ts b/src/modules/core/currencies.service.ts new file mode 100644 index 0000000..2d0e988 --- /dev/null +++ b/src/modules/core/currencies.service.ts @@ -0,0 +1,118 @@ +import { Repository } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { Currency } from './entities/currency.entity.js'; +import { NotFoundError, ConflictError } from '../../shared/errors/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +export interface CreateCurrencyDto { + code: string; + name: string; + symbol: string; + decimal_places?: number; + decimals?: number; // Accept camelCase too +} + +export interface UpdateCurrencyDto { + name?: string; + symbol?: string; + decimal_places?: number; + decimals?: number; // Accept camelCase too + active?: boolean; +} + +class CurrenciesService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(Currency); + } + + async findAll(activeOnly: boolean = false): Promise { + logger.debug('Finding all currencies', { activeOnly }); + + const queryBuilder = this.repository + .createQueryBuilder('currency') + .orderBy('currency.code', 'ASC'); + + if (activeOnly) { + queryBuilder.where('currency.active = :active', { active: true }); + } + + return queryBuilder.getMany(); + } + + async findById(id: string): Promise { + logger.debug('Finding currency by id', { id }); + + const currency = await this.repository.findOne({ + where: { id }, + }); + + if (!currency) { + throw new NotFoundError('Moneda no encontrada'); + } + + return currency; + } + + async findByCode(code: string): Promise { + logger.debug('Finding currency by code', { code }); + + return this.repository.findOne({ + where: { code: code.toUpperCase() }, + }); + } + + async create(dto: CreateCurrencyDto): Promise { + logger.debug('Creating currency', { code: dto.code }); + + const existing = await this.findByCode(dto.code); + if (existing) { + throw new ConflictError(`Ya existe una moneda con código ${dto.code}`); + } + + // Accept both snake_case and camelCase + const decimals = dto.decimal_places ?? dto.decimals ?? 2; + + const currency = this.repository.create({ + code: dto.code.toUpperCase(), + name: dto.name, + symbol: dto.symbol, + decimals, + }); + + const saved = await this.repository.save(currency); + logger.info('Currency created', { id: saved.id, code: saved.code }); + + return saved; + } + + async update(id: string, dto: UpdateCurrencyDto): Promise { + logger.debug('Updating currency', { id }); + + const currency = await this.findById(id); + + // Accept both snake_case and camelCase + const decimals = dto.decimal_places ?? dto.decimals; + + if (dto.name !== undefined) { + currency.name = dto.name; + } + if (dto.symbol !== undefined) { + currency.symbol = dto.symbol; + } + if (decimals !== undefined) { + currency.decimals = decimals; + } + if (dto.active !== undefined) { + currency.active = dto.active; + } + + const updated = await this.repository.save(currency); + logger.info('Currency updated', { id: updated.id, code: updated.code }); + + return updated; + } +} + +export const currenciesService = new CurrenciesService(); diff --git a/src/modules/core/entities/country.entity.ts b/src/modules/core/entities/country.entity.ts new file mode 100644 index 0000000..e3a6384 --- /dev/null +++ b/src/modules/core/entities/country.entity.ts @@ -0,0 +1,35 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ schema: 'core', name: 'countries' }) +@Index('idx_countries_code', ['code'], { unique: true }) +export class Country { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 2, nullable: false, unique: true }) + code: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 10, nullable: true, name: 'phone_code' }) + phoneCode: string | null; + + @Column({ + type: 'varchar', + length: 3, + nullable: true, + name: 'currency_code', + }) + currencyCode: string | null; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} diff --git a/src/modules/core/entities/currency.entity.ts b/src/modules/core/entities/currency.entity.ts new file mode 100644 index 0000000..f322222 --- /dev/null +++ b/src/modules/core/entities/currency.entity.ts @@ -0,0 +1,43 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ schema: 'core', name: 'currencies' }) +@Index('idx_currencies_code', ['code'], { unique: true }) +@Index('idx_currencies_active', ['active']) +export class Currency { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 3, nullable: false, unique: true }) + code: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 10, nullable: false }) + symbol: string; + + @Column({ type: 'integer', nullable: false, default: 2, name: 'decimals' }) + decimals: number; + + @Column({ + type: 'decimal', + precision: 12, + scale: 6, + nullable: true, + default: 0.01, + }) + rounding: number; + + @Column({ type: 'boolean', nullable: false, default: true }) + active: boolean; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} diff --git a/src/modules/core/entities/index.ts b/src/modules/core/entities/index.ts new file mode 100644 index 0000000..fda5d7a --- /dev/null +++ b/src/modules/core/entities/index.ts @@ -0,0 +1,6 @@ +export { Currency } from './currency.entity.js'; +export { Country } from './country.entity.js'; +export { UomCategory } from './uom-category.entity.js'; +export { Uom, UomType } from './uom.entity.js'; +export { ProductCategory } from './product-category.entity.js'; +export { Sequence, ResetPeriod } from './sequence.entity.js'; diff --git a/src/modules/core/entities/product-category.entity.ts b/src/modules/core/entities/product-category.entity.ts new file mode 100644 index 0000000..d9fdd08 --- /dev/null +++ b/src/modules/core/entities/product-category.entity.ts @@ -0,0 +1,79 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; + +@Entity({ schema: 'core', name: 'product_categories' }) +@Index('idx_product_categories_tenant_id', ['tenantId']) +@Index('idx_product_categories_parent_id', ['parentId']) +@Index('idx_product_categories_code_tenant', ['tenantId', 'code'], { + unique: true, +}) +@Index('idx_product_categories_active', ['tenantId', 'active'], { + where: 'deleted_at IS NULL', +}) +export class ProductCategory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + code: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'parent_id' }) + parentId: string | null; + + @Column({ type: 'text', nullable: true, name: 'full_path' }) + fullPath: string | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column({ type: 'boolean', nullable: false, default: true }) + active: boolean; + + // Relations + @ManyToOne(() => ProductCategory, (category) => category.children, { + nullable: true, + }) + @JoinColumn({ name: 'parent_id' }) + parent: ProductCategory | null; + + @OneToMany(() => ProductCategory, (category) => category.parent) + children: ProductCategory[]; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + deletedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) + deletedBy: string | null; +} diff --git a/src/modules/core/entities/sequence.entity.ts b/src/modules/core/entities/sequence.entity.ts new file mode 100644 index 0000000..cc28829 --- /dev/null +++ b/src/modules/core/entities/sequence.entity.ts @@ -0,0 +1,83 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export enum ResetPeriod { + NONE = 'none', + YEAR = 'year', + MONTH = 'month', +} + +@Entity({ schema: 'core', name: 'sequences' }) +@Index('idx_sequences_tenant_id', ['tenantId']) +@Index('idx_sequences_code_tenant', ['tenantId', 'code'], { unique: true }) +@Index('idx_sequences_active', ['tenantId', 'isActive']) +export class Sequence { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: true, name: 'company_id' }) + companyId: string | null; + + @Column({ type: 'varchar', length: 100, nullable: false }) + code: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + prefix: string | null; + + @Column({ type: 'varchar', length: 50, nullable: true }) + suffix: string | null; + + @Column({ type: 'integer', nullable: false, default: 1, name: 'next_number' }) + nextNumber: number; + + @Column({ type: 'integer', nullable: false, default: 4 }) + padding: number; + + @Column({ + type: 'enum', + enum: ResetPeriod, + nullable: true, + default: ResetPeriod.NONE, + name: 'reset_period', + }) + resetPeriod: ResetPeriod | null; + + @Column({ + type: 'timestamp', + nullable: true, + name: 'last_reset_date', + }) + lastResetDate: Date | null; + + @Column({ type: 'boolean', nullable: false, default: true, name: 'is_active' }) + isActive: boolean; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; +} diff --git a/src/modules/core/entities/uom-category.entity.ts b/src/modules/core/entities/uom-category.entity.ts new file mode 100644 index 0000000..c115800 --- /dev/null +++ b/src/modules/core/entities/uom-category.entity.ts @@ -0,0 +1,30 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { Uom } from './uom.entity.js'; + +@Entity({ schema: 'core', name: 'uom_categories' }) +@Index('idx_uom_categories_name', ['name'], { unique: true }) +export class UomCategory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 100, nullable: false, unique: true }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + // Relations + @OneToMany(() => Uom, (uom) => uom.category) + uoms: Uom[]; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} diff --git a/src/modules/core/entities/uom.entity.ts b/src/modules/core/entities/uom.entity.ts new file mode 100644 index 0000000..98ba8aa --- /dev/null +++ b/src/modules/core/entities/uom.entity.ts @@ -0,0 +1,76 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { UomCategory } from './uom-category.entity.js'; + +export enum UomType { + REFERENCE = 'reference', + BIGGER = 'bigger', + SMALLER = 'smaller', +} + +@Entity({ schema: 'core', name: 'uom' }) +@Index('idx_uom_category_id', ['categoryId']) +@Index('idx_uom_code', ['code']) +@Index('idx_uom_active', ['active']) +@Index('idx_uom_name_category', ['categoryId', 'name'], { unique: true }) +export class Uom { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'category_id' }) + categoryId: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + code: string | null; + + @Column({ + type: 'enum', + enum: UomType, + nullable: false, + default: UomType.REFERENCE, + name: 'uom_type', + }) + uomType: UomType; + + @Column({ + type: 'decimal', + precision: 12, + scale: 6, + nullable: false, + default: 1.0, + }) + factor: number; + + @Column({ + type: 'decimal', + precision: 12, + scale: 6, + nullable: true, + default: 0.01, + }) + rounding: number; + + @Column({ type: 'boolean', nullable: false, default: true }) + active: boolean; + + // Relations + @ManyToOne(() => UomCategory, (category) => category.uoms, { + nullable: false, + }) + @JoinColumn({ name: 'category_id' }) + category: UomCategory; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} diff --git a/src/modules/core/index.ts b/src/modules/core/index.ts new file mode 100644 index 0000000..10a620d --- /dev/null +++ b/src/modules/core/index.ts @@ -0,0 +1,8 @@ +export * from './currencies.service.js'; +export * from './countries.service.js'; +export * from './uom.service.js'; +export * from './product-categories.service.js'; +export * from './sequences.service.js'; +export * from './entities/index.js'; +export * from './core.controller.js'; +export { default as coreRoutes } from './core.routes.js'; diff --git a/src/modules/core/product-categories.service.ts b/src/modules/core/product-categories.service.ts new file mode 100644 index 0000000..8401c99 --- /dev/null +++ b/src/modules/core/product-categories.service.ts @@ -0,0 +1,223 @@ +import { Repository, IsNull } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { ProductCategory } from './entities/product-category.entity.js'; +import { NotFoundError, ConflictError } from '../../shared/errors/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +export interface CreateProductCategoryDto { + name: string; + code: string; + parent_id?: string; + parentId?: string; // Accept camelCase too +} + +export interface UpdateProductCategoryDto { + name?: string; + parent_id?: string | null; + parentId?: string | null; // Accept camelCase too + active?: boolean; +} + +class ProductCategoriesService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(ProductCategory); + } + + async findAll( + tenantId: string, + parentId?: string, + activeOnly: boolean = false + ): Promise { + logger.debug('Finding all product categories', { + tenantId, + parentId, + activeOnly, + }); + + const queryBuilder = this.repository + .createQueryBuilder('pc') + .leftJoinAndSelect('pc.parent', 'parent') + .where('pc.tenantId = :tenantId', { tenantId }) + .andWhere('pc.deletedAt IS NULL'); + + if (parentId !== undefined) { + if (parentId === null || parentId === 'null') { + queryBuilder.andWhere('pc.parentId IS NULL'); + } else { + queryBuilder.andWhere('pc.parentId = :parentId', { parentId }); + } + } + + if (activeOnly) { + queryBuilder.andWhere('pc.active = :active', { active: true }); + } + + queryBuilder.orderBy('pc.name', 'ASC'); + + return queryBuilder.getMany(); + } + + async findById(id: string, tenantId: string): Promise { + logger.debug('Finding product category by id', { id, tenantId }); + + const category = await this.repository.findOne({ + where: { + id, + tenantId, + deletedAt: IsNull(), + }, + relations: ['parent'], + }); + + if (!category) { + throw new NotFoundError('Categoría de producto no encontrada'); + } + + return category; + } + + async create( + dto: CreateProductCategoryDto, + tenantId: string, + userId: string + ): Promise { + logger.debug('Creating product category', { dto, tenantId, userId }); + + // Accept both snake_case and camelCase + const parentId = dto.parent_id ?? dto.parentId; + + // Check unique code within tenant + const existing = await this.repository.findOne({ + where: { + tenantId, + code: dto.code, + deletedAt: IsNull(), + }, + }); + + if (existing) { + throw new ConflictError(`Ya existe una categoría con código ${dto.code}`); + } + + // Validate parent if specified + if (parentId) { + const parent = await this.repository.findOne({ + where: { + id: parentId, + tenantId, + deletedAt: IsNull(), + }, + }); + + if (!parent) { + throw new NotFoundError('Categoría padre no encontrada'); + } + } + + const category = this.repository.create({ + tenantId, + name: dto.name, + code: dto.code, + parentId: parentId || null, + createdBy: userId, + }); + + const saved = await this.repository.save(category); + logger.info('Product category created', { + id: saved.id, + code: saved.code, + tenantId, + }); + + return saved; + } + + async update( + id: string, + dto: UpdateProductCategoryDto, + tenantId: string, + userId: string + ): Promise { + logger.debug('Updating product category', { id, dto, tenantId, userId }); + + const category = await this.findById(id, tenantId); + + // Accept both snake_case and camelCase + const parentId = dto.parent_id ?? dto.parentId; + + // Validate parent (prevent self-reference) + if (parentId !== undefined) { + if (parentId === id) { + throw new ConflictError('Una categoría no puede ser su propio padre'); + } + + if (parentId !== null) { + const parent = await this.repository.findOne({ + where: { + id: parentId, + tenantId, + deletedAt: IsNull(), + }, + }); + + if (!parent) { + throw new NotFoundError('Categoría padre no encontrada'); + } + } + + category.parentId = parentId; + } + + if (dto.name !== undefined) { + category.name = dto.name; + } + + if (dto.active !== undefined) { + category.active = dto.active; + } + + category.updatedBy = userId; + + const updated = await this.repository.save(category); + logger.info('Product category updated', { + id: updated.id, + code: updated.code, + tenantId, + }); + + return updated; + } + + async delete(id: string, tenantId: string): Promise { + logger.debug('Deleting product category', { id, tenantId }); + + const category = await this.findById(id, tenantId); + + // Check if has children + const childrenCount = await this.repository.count({ + where: { + parentId: id, + tenantId, + deletedAt: IsNull(), + }, + }); + + if (childrenCount > 0) { + throw new ConflictError( + 'No se puede eliminar una categoría que tiene subcategorías' + ); + } + + // Note: We should check for products in inventory schema + // For now, we'll just perform a hard delete as in original + // In a real scenario, you'd want to check inventory.products table + + await this.repository.delete({ id, tenantId }); + + logger.info('Product category deleted', { id, tenantId }); + } +} + +export const productCategoriesService = new ProductCategoriesService(); diff --git a/src/modules/core/sequences.service.ts b/src/modules/core/sequences.service.ts new file mode 100644 index 0000000..7c5982a --- /dev/null +++ b/src/modules/core/sequences.service.ts @@ -0,0 +1,466 @@ +import { Repository, DataSource } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { Sequence, ResetPeriod } from './entities/sequence.entity.js'; +import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface CreateSequenceDto { + code: string; + name: string; + prefix?: string; + suffix?: string; + start_number?: number; + startNumber?: number; // Accept camelCase too + padding?: number; + reset_period?: 'none' | 'year' | 'month'; + resetPeriod?: 'none' | 'year' | 'month'; // Accept camelCase too +} + +export interface UpdateSequenceDto { + name?: string; + prefix?: string | null; + suffix?: string | null; + padding?: number; + reset_period?: 'none' | 'year' | 'month'; + resetPeriod?: 'none' | 'year' | 'month'; // Accept camelCase too + is_active?: boolean; + isActive?: boolean; // Accept camelCase too +} + +// ============================================================================ +// PREDEFINED SEQUENCE CODES +// ============================================================================ + +export const SEQUENCE_CODES = { + // Sales + SALES_ORDER: 'SO', + QUOTATION: 'QT', + + // Purchases + PURCHASE_ORDER: 'PO', + RFQ: 'RFQ', + + // Inventory + PICKING_IN: 'WH/IN', + PICKING_OUT: 'WH/OUT', + PICKING_INT: 'WH/INT', + INVENTORY_ADJ: 'INV/ADJ', + + // Financial + INVOICE_CUSTOMER: 'INV', + INVOICE_SUPPLIER: 'BILL', + PAYMENT: 'PAY', + JOURNAL_ENTRY: 'JE', + + // CRM + LEAD: 'LEAD', + OPPORTUNITY: 'OPP', + + // Projects + PROJECT: 'PRJ', + TASK: 'TASK', + + // HR + EMPLOYEE: 'EMP', + CONTRACT: 'CTR', +} as const; + +// ============================================================================ +// SERVICE +// ============================================================================ + +class SequencesService { + private repository: Repository; + private dataSource: DataSource; + + constructor() { + this.repository = AppDataSource.getRepository(Sequence); + this.dataSource = AppDataSource; + } + + /** + * Get the next number in a sequence using the database function + * This is atomic and handles concurrent requests safely + */ + async getNextNumber( + sequenceCode: string, + tenantId: string, + queryRunner?: any + ): Promise { + logger.debug('Generating next sequence number', { sequenceCode, tenantId }); + + const executeQuery = queryRunner + ? (sql: string, params: any[]) => queryRunner.query(sql, params) + : (sql: string, params: any[]) => this.dataSource.query(sql, params); + + try { + // Use the database function for atomic sequence generation + const result = await executeQuery( + `SELECT core.generate_next_sequence($1, $2) as sequence_number`, + [sequenceCode, tenantId] + ); + + if (!result?.[0]?.sequence_number) { + // Sequence doesn't exist, try to create it with default settings + logger.warn('Sequence not found, creating default', { + sequenceCode, + tenantId, + }); + + await this.ensureSequenceExists(sequenceCode, tenantId, queryRunner); + + // Try again + const retryResult = await executeQuery( + `SELECT core.generate_next_sequence($1, $2) as sequence_number`, + [sequenceCode, tenantId] + ); + + if (!retryResult?.[0]?.sequence_number) { + throw new NotFoundError(`Secuencia ${sequenceCode} no encontrada`); + } + + logger.debug('Generated sequence number after creating default', { + sequenceCode, + number: retryResult[0].sequence_number, + }); + + return retryResult[0].sequence_number; + } + + logger.debug('Generated sequence number', { + sequenceCode, + number: result[0].sequence_number, + }); + + return result[0].sequence_number; + } catch (error) { + logger.error('Error generating sequence number', { + sequenceCode, + tenantId, + error: (error as Error).message, + }); + throw error; + } + } + + /** + * Ensure a sequence exists, creating it with defaults if not + */ + async ensureSequenceExists( + sequenceCode: string, + tenantId: string, + queryRunner?: any + ): Promise { + logger.debug('Ensuring sequence exists', { sequenceCode, tenantId }); + + // Check if exists + const existing = await this.repository.findOne({ + where: { code: sequenceCode, tenantId }, + }); + + if (existing) { + logger.debug('Sequence already exists', { sequenceCode, tenantId }); + return; + } + + // Create with defaults based on code + const defaults = this.getDefaultsForCode(sequenceCode); + + const sequence = this.repository.create({ + tenantId, + code: sequenceCode, + name: defaults.name, + prefix: defaults.prefix, + padding: defaults.padding, + nextNumber: 1, + }); + + await this.repository.save(sequence); + + logger.info('Created default sequence', { sequenceCode, tenantId }); + } + + /** + * Get default settings for a sequence code + */ + private getDefaultsForCode(code: string): { + name: string; + prefix: string; + padding: number; + } { + const defaults: Record< + string, + { name: string; prefix: string; padding: number } + > = { + [SEQUENCE_CODES.SALES_ORDER]: { + name: 'Órdenes de Venta', + prefix: 'SO-', + padding: 5, + }, + [SEQUENCE_CODES.QUOTATION]: { + name: 'Cotizaciones', + prefix: 'QT-', + padding: 5, + }, + [SEQUENCE_CODES.PURCHASE_ORDER]: { + name: 'Órdenes de Compra', + prefix: 'PO-', + padding: 5, + }, + [SEQUENCE_CODES.RFQ]: { + name: 'Solicitudes de Cotización', + prefix: 'RFQ-', + padding: 5, + }, + [SEQUENCE_CODES.PICKING_IN]: { + name: 'Recepciones', + prefix: 'WH/IN/', + padding: 5, + }, + [SEQUENCE_CODES.PICKING_OUT]: { + name: 'Entregas', + prefix: 'WH/OUT/', + padding: 5, + }, + [SEQUENCE_CODES.PICKING_INT]: { + name: 'Transferencias', + prefix: 'WH/INT/', + padding: 5, + }, + [SEQUENCE_CODES.INVENTORY_ADJ]: { + name: 'Ajustes de Inventario', + prefix: 'ADJ/', + padding: 5, + }, + [SEQUENCE_CODES.INVOICE_CUSTOMER]: { + name: 'Facturas de Cliente', + prefix: 'INV/', + padding: 6, + }, + [SEQUENCE_CODES.INVOICE_SUPPLIER]: { + name: 'Facturas de Proveedor', + prefix: 'BILL/', + padding: 6, + }, + [SEQUENCE_CODES.PAYMENT]: { name: 'Pagos', prefix: 'PAY/', padding: 5 }, + [SEQUENCE_CODES.JOURNAL_ENTRY]: { + name: 'Asientos Contables', + prefix: 'JE/', + padding: 6, + }, + [SEQUENCE_CODES.LEAD]: { name: 'Prospectos', prefix: 'LEAD-', padding: 5 }, + [SEQUENCE_CODES.OPPORTUNITY]: { + name: 'Oportunidades', + prefix: 'OPP-', + padding: 5, + }, + [SEQUENCE_CODES.PROJECT]: { + name: 'Proyectos', + prefix: 'PRJ-', + padding: 4, + }, + [SEQUENCE_CODES.TASK]: { name: 'Tareas', prefix: 'TASK-', padding: 5 }, + [SEQUENCE_CODES.EMPLOYEE]: { + name: 'Empleados', + prefix: 'EMP-', + padding: 4, + }, + [SEQUENCE_CODES.CONTRACT]: { + name: 'Contratos', + prefix: 'CTR-', + padding: 5, + }, + }; + + return defaults[code] || { name: code, prefix: `${code}-`, padding: 5 }; + } + + /** + * Get all sequences for a tenant + */ + async findAll(tenantId: string): Promise { + logger.debug('Finding all sequences', { tenantId }); + + return this.repository.find({ + where: { tenantId }, + order: { code: 'ASC' }, + }); + } + + /** + * Get a specific sequence by code + */ + async findByCode(code: string, tenantId: string): Promise { + logger.debug('Finding sequence by code', { code, tenantId }); + + return this.repository.findOne({ + where: { code, tenantId }, + }); + } + + /** + * Create a new sequence + */ + async create(dto: CreateSequenceDto, tenantId: string): Promise { + logger.debug('Creating sequence', { dto, tenantId }); + + // Check for existing + const existing = await this.findByCode(dto.code, tenantId); + if (existing) { + throw new ValidationError( + `Ya existe una secuencia con código ${dto.code}` + ); + } + + // Accept both snake_case and camelCase + const startNumber = dto.start_number ?? dto.startNumber ?? 1; + const resetPeriod = dto.reset_period ?? dto.resetPeriod ?? 'none'; + + const sequence = this.repository.create({ + tenantId, + code: dto.code, + name: dto.name, + prefix: dto.prefix || null, + suffix: dto.suffix || null, + nextNumber: startNumber, + padding: dto.padding || 5, + resetPeriod: resetPeriod as ResetPeriod, + }); + + const saved = await this.repository.save(sequence); + + logger.info('Sequence created', { code: dto.code, tenantId }); + + return saved; + } + + /** + * Update a sequence + */ + async update( + code: string, + dto: UpdateSequenceDto, + tenantId: string + ): Promise { + logger.debug('Updating sequence', { code, dto, tenantId }); + + const existing = await this.findByCode(code, tenantId); + if (!existing) { + throw new NotFoundError('Secuencia no encontrada'); + } + + // Accept both snake_case and camelCase + const resetPeriod = dto.reset_period ?? dto.resetPeriod; + const isActive = dto.is_active ?? dto.isActive; + + if (dto.name !== undefined) { + existing.name = dto.name; + } + if (dto.prefix !== undefined) { + existing.prefix = dto.prefix; + } + if (dto.suffix !== undefined) { + existing.suffix = dto.suffix; + } + if (dto.padding !== undefined) { + existing.padding = dto.padding; + } + if (resetPeriod !== undefined) { + existing.resetPeriod = resetPeriod as ResetPeriod; + } + if (isActive !== undefined) { + existing.isActive = isActive; + } + + const updated = await this.repository.save(existing); + + logger.info('Sequence updated', { code, tenantId }); + + return updated; + } + + /** + * Reset a sequence to a specific number + */ + async reset( + code: string, + tenantId: string, + newNumber: number = 1 + ): Promise { + logger.debug('Resetting sequence', { code, tenantId, newNumber }); + + const sequence = await this.findByCode(code, tenantId); + if (!sequence) { + throw new NotFoundError('Secuencia no encontrada'); + } + + sequence.nextNumber = newNumber; + sequence.lastResetDate = new Date(); + + const updated = await this.repository.save(sequence); + + logger.info('Sequence reset', { code, tenantId, newNumber }); + + return updated; + } + + /** + * Preview what the next number would be (without incrementing) + */ + async preview(code: string, tenantId: string): Promise { + logger.debug('Previewing next sequence number', { code, tenantId }); + + const sequence = await this.findByCode(code, tenantId); + if (!sequence) { + throw new NotFoundError('Secuencia no encontrada'); + } + + const paddedNumber = String(sequence.nextNumber).padStart( + sequence.padding, + '0' + ); + const prefix = sequence.prefix || ''; + const suffix = sequence.suffix || ''; + + return `${prefix}${paddedNumber}${suffix}`; + } + + /** + * Initialize all standard sequences for a new tenant + */ + async initializeForTenant(tenantId: string): Promise { + logger.debug('Initializing sequences for tenant', { tenantId }); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + for (const [key, code] of Object.entries(SEQUENCE_CODES)) { + await this.ensureSequenceExists(code, tenantId, queryRunner); + } + + await queryRunner.commitTransaction(); + + logger.info('Initialized sequences for tenant', { + tenantId, + count: Object.keys(SEQUENCE_CODES).length, + }); + } catch (error) { + await queryRunner.rollbackTransaction(); + logger.error('Error initializing sequences for tenant', { + tenantId, + error: (error as Error).message, + }); + throw error; + } finally { + await queryRunner.release(); + } + } +} + +export const sequencesService = new SequencesService(); diff --git a/src/modules/core/uom.service.ts b/src/modules/core/uom.service.ts new file mode 100644 index 0000000..dc3abd6 --- /dev/null +++ b/src/modules/core/uom.service.ts @@ -0,0 +1,162 @@ +import { Repository } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { Uom, UomType } from './entities/uom.entity.js'; +import { UomCategory } from './entities/uom-category.entity.js'; +import { NotFoundError, ConflictError } from '../../shared/errors/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +export interface CreateUomDto { + name: string; + code: string; + category_id?: string; + categoryId?: string; // Accept camelCase too + uom_type?: 'reference' | 'bigger' | 'smaller'; + uomType?: 'reference' | 'bigger' | 'smaller'; // Accept camelCase too + ratio?: number; +} + +export interface UpdateUomDto { + name?: string; + ratio?: number; + active?: boolean; +} + +class UomService { + private repository: Repository; + private categoryRepository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(Uom); + this.categoryRepository = AppDataSource.getRepository(UomCategory); + } + + // Categories + async findAllCategories(activeOnly: boolean = false): Promise { + logger.debug('Finding all UOM categories', { activeOnly }); + + const queryBuilder = this.categoryRepository + .createQueryBuilder('category') + .orderBy('category.name', 'ASC'); + + // Note: activeOnly is not supported since the table doesn't have an active field + // Keeping the parameter for backward compatibility + + return queryBuilder.getMany(); + } + + async findCategoryById(id: string): Promise { + logger.debug('Finding UOM category by id', { id }); + + const category = await this.categoryRepository.findOne({ + where: { id }, + }); + + if (!category) { + throw new NotFoundError('Categoría de UdM no encontrada'); + } + + return category; + } + + // UoM + async findAll(categoryId?: string, activeOnly: boolean = false): Promise { + logger.debug('Finding all UOMs', { categoryId, activeOnly }); + + const queryBuilder = this.repository + .createQueryBuilder('u') + .leftJoinAndSelect('u.category', 'uc') + .orderBy('uc.name', 'ASC') + .addOrderBy('u.uomType', 'ASC') + .addOrderBy('u.name', 'ASC'); + + if (categoryId) { + queryBuilder.where('u.categoryId = :categoryId', { categoryId }); + } + + if (activeOnly) { + queryBuilder.andWhere('u.active = :active', { active: true }); + } + + return queryBuilder.getMany(); + } + + async findById(id: string): Promise { + logger.debug('Finding UOM by id', { id }); + + const uom = await this.repository.findOne({ + where: { id }, + relations: ['category'], + }); + + if (!uom) { + throw new NotFoundError('Unidad de medida no encontrada'); + } + + return uom; + } + + async create(dto: CreateUomDto): Promise { + logger.debug('Creating UOM', { dto }); + + // Accept both snake_case and camelCase + const categoryId = dto.category_id ?? dto.categoryId; + const uomType = dto.uom_type ?? dto.uomType ?? 'reference'; + const factor = dto.ratio ?? 1; + + if (!categoryId) { + throw new NotFoundError('category_id es requerido'); + } + + // Validate category exists + await this.findCategoryById(categoryId); + + // Check unique code + if (dto.code) { + const existing = await this.repository.findOne({ + where: { code: dto.code }, + }); + + if (existing) { + throw new ConflictError(`Ya existe una UdM con código ${dto.code}`); + } + } + + const uom = this.repository.create({ + name: dto.name, + code: dto.code, + categoryId, + uomType: uomType as UomType, + factor, + }); + + const saved = await this.repository.save(uom); + logger.info('UOM created', { id: saved.id, code: saved.code }); + + return saved; + } + + async update(id: string, dto: UpdateUomDto): Promise { + logger.debug('Updating UOM', { id, dto }); + + const uom = await this.findById(id); + + if (dto.name !== undefined) { + uom.name = dto.name; + } + + if (dto.ratio !== undefined) { + uom.factor = dto.ratio; + } + + if (dto.active !== undefined) { + uom.active = dto.active; + } + + const updated = await this.repository.save(uom); + logger.info('UOM updated', { id: updated.id, code: updated.code }); + + return updated; + } +} + +export const uomService = new UomService(); diff --git a/src/modules/crm/crm.controller.ts b/src/modules/crm/crm.controller.ts new file mode 100644 index 0000000..d69bce6 --- /dev/null +++ b/src/modules/crm/crm.controller.ts @@ -0,0 +1,682 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { leadsService, CreateLeadDto, UpdateLeadDto, LeadFilters } from './leads.service.js'; +import { opportunitiesService, CreateOpportunityDto, UpdateOpportunityDto, OpportunityFilters } from './opportunities.service.js'; +import { stagesService, CreateLeadStageDto, UpdateLeadStageDto, CreateOpportunityStageDto, UpdateOpportunityStageDto, CreateLostReasonDto, UpdateLostReasonDto } from './stages.service.js'; +import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js'; +import { ValidationError } from '../../shared/errors/index.js'; + +// Lead schemas +const createLeadSchema = z.object({ + company_id: z.string().uuid(), + name: z.string().min(1).max(255), + ref: z.string().max(100).optional(), + contact_name: z.string().max(255).optional(), + email: z.string().email().max(255).optional(), + phone: z.string().max(50).optional(), + mobile: z.string().max(50).optional(), + website: z.string().url().max(255).optional(), + company_prospect_name: z.string().max(255).optional(), + job_position: z.string().max(100).optional(), + industry: z.string().max(100).optional(), + employee_count: z.string().max(50).optional(), + annual_revenue: z.number().min(0).optional(), + street: z.string().max(255).optional(), + city: z.string().max(100).optional(), + state: z.string().max(100).optional(), + zip: z.string().max(20).optional(), + country: z.string().max(100).optional(), + stage_id: z.string().uuid().optional(), + user_id: z.string().uuid().optional(), + sales_team_id: z.string().uuid().optional(), + source: z.enum(['website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other']).optional(), + priority: z.number().int().min(0).max(3).optional(), + probability: z.number().min(0).max(100).optional(), + expected_revenue: z.number().min(0).optional(), + date_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + description: z.string().optional(), + notes: z.string().optional(), + tags: z.array(z.string()).optional(), +}); + +const updateLeadSchema = z.object({ + name: z.string().min(1).max(255).optional(), + ref: z.string().max(100).optional().nullable(), + contact_name: z.string().max(255).optional().nullable(), + email: z.string().email().max(255).optional().nullable(), + phone: z.string().max(50).optional().nullable(), + mobile: z.string().max(50).optional().nullable(), + website: z.string().url().max(255).optional().nullable(), + company_prospect_name: z.string().max(255).optional().nullable(), + job_position: z.string().max(100).optional().nullable(), + industry: z.string().max(100).optional().nullable(), + employee_count: z.string().max(50).optional().nullable(), + annual_revenue: z.number().min(0).optional().nullable(), + street: z.string().max(255).optional().nullable(), + city: z.string().max(100).optional().nullable(), + state: z.string().max(100).optional().nullable(), + zip: z.string().max(20).optional().nullable(), + country: z.string().max(100).optional().nullable(), + stage_id: z.string().uuid().optional().nullable(), + user_id: z.string().uuid().optional().nullable(), + sales_team_id: z.string().uuid().optional().nullable(), + source: z.enum(['website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other']).optional().nullable(), + priority: z.number().int().min(0).max(3).optional(), + probability: z.number().min(0).max(100).optional(), + expected_revenue: z.number().min(0).optional().nullable(), + date_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), + description: z.string().optional().nullable(), + notes: z.string().optional().nullable(), + tags: z.array(z.string()).optional().nullable(), +}); + +const leadQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + status: z.enum(['new', 'contacted', 'qualified', 'converted', 'lost']).optional(), + stage_id: z.string().uuid().optional(), + user_id: z.string().uuid().optional(), + source: z.enum(['website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other']).optional(), + priority: z.coerce.number().int().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +const lostSchema = z.object({ + lost_reason_id: z.string().uuid(), + notes: z.string().optional(), +}); + +const moveStageSchema = z.object({ + stage_id: z.string().uuid(), +}); + +// Opportunity schemas +const createOpportunitySchema = z.object({ + company_id: z.string().uuid(), + name: z.string().min(1).max(255), + ref: z.string().max(100).optional(), + partner_id: z.string().uuid(), + contact_name: z.string().max(255).optional(), + email: z.string().email().max(255).optional(), + phone: z.string().max(50).optional(), + stage_id: z.string().uuid().optional(), + user_id: z.string().uuid().optional(), + sales_team_id: z.string().uuid().optional(), + priority: z.number().int().min(0).max(3).optional(), + probability: z.number().min(0).max(100).optional(), + expected_revenue: z.number().min(0).optional(), + recurring_revenue: z.number().min(0).optional(), + recurring_plan: z.string().max(50).optional(), + date_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + source: z.enum(['website', 'phone', 'email', 'referral', 'social_media', 'advertising', 'event', 'other']).optional(), + description: z.string().optional(), + notes: z.string().optional(), + tags: z.array(z.string()).optional(), +}); + +const updateOpportunitySchema = z.object({ + name: z.string().min(1).max(255).optional(), + ref: z.string().max(100).optional().nullable(), + partner_id: z.string().uuid().optional(), + contact_name: z.string().max(255).optional().nullable(), + email: z.string().email().max(255).optional().nullable(), + phone: z.string().max(50).optional().nullable(), + stage_id: z.string().uuid().optional().nullable(), + user_id: z.string().uuid().optional().nullable(), + sales_team_id: z.string().uuid().optional().nullable(), + priority: z.number().int().min(0).max(3).optional(), + probability: z.number().min(0).max(100).optional(), + expected_revenue: z.number().min(0).optional().nullable(), + recurring_revenue: z.number().min(0).optional().nullable(), + recurring_plan: z.string().max(50).optional().nullable(), + date_deadline: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), + description: z.string().optional().nullable(), + notes: z.string().optional().nullable(), + tags: z.array(z.string()).optional().nullable(), +}); + +const opportunityQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + status: z.enum(['open', 'won', 'lost']).optional(), + stage_id: z.string().uuid().optional(), + user_id: z.string().uuid().optional(), + partner_id: z.string().uuid().optional(), + priority: z.coerce.number().int().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +// Stage schemas +const createStageSchema = z.object({ + name: z.string().min(1).max(100), + sequence: z.number().int().optional(), + is_won: z.boolean().optional(), + probability: z.number().min(0).max(100).optional(), + requirements: z.string().optional(), +}); + +const updateStageSchema = z.object({ + name: z.string().min(1).max(100).optional(), + sequence: z.number().int().optional(), + is_won: z.boolean().optional(), + probability: z.number().min(0).max(100).optional(), + requirements: z.string().optional().nullable(), + active: z.boolean().optional(), +}); + +// Lost reason schemas +const createLostReasonSchema = z.object({ + name: z.string().min(1).max(100), + description: z.string().optional(), +}); + +const updateLostReasonSchema = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().optional().nullable(), + active: z.boolean().optional(), +}); + +class CrmController { + // ========== LEADS ========== + + async getLeads(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = leadQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors); + } + + const filters: LeadFilters = queryResult.data; + const result = await leadsService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + async getLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const lead = await leadsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: lead }); + } catch (error) { + next(error); + } + } + + async createLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createLeadSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de lead invalidos', parseResult.error.errors); + } + + const dto: CreateLeadDto = parseResult.data; + const lead = await leadsService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: lead, + message: 'Lead creado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateLeadSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de lead invalidos', parseResult.error.errors); + } + + const dto: UpdateLeadDto = parseResult.data; + const lead = await leadsService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: lead, + message: 'Lead actualizado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async moveLeadStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = moveStageSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos invalidos', parseResult.error.errors); + } + + const lead = await leadsService.moveStage(req.params.id, parseResult.data.stage_id, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: lead, + message: 'Lead movido a nueva etapa', + }); + } catch (error) { + next(error); + } + } + + async convertLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const result = await leadsService.convert(req.params.id, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: result.lead, + opportunity_id: result.opportunity_id, + message: 'Lead convertido a oportunidad exitosamente', + }); + } catch (error) { + next(error); + } + } + + async markLeadLost(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = lostSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos invalidos', parseResult.error.errors); + } + + const lead = await leadsService.markLost( + req.params.id, + parseResult.data.lost_reason_id, + parseResult.data.notes, + req.tenantId!, + req.user!.userId + ); + + res.json({ + success: true, + data: lead, + message: 'Lead marcado como perdido', + }); + } catch (error) { + next(error); + } + } + + async deleteLead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await leadsService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Lead eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== OPPORTUNITIES ========== + + async getOpportunities(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = opportunityQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors); + } + + const filters: OpportunityFilters = queryResult.data; + const result = await opportunitiesService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + async getOpportunity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const opportunity = await opportunitiesService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: opportunity }); + } catch (error) { + next(error); + } + } + + async createOpportunity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createOpportunitySchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de oportunidad invalidos', parseResult.error.errors); + } + + const dto: CreateOpportunityDto = parseResult.data; + const opportunity = await opportunitiesService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: opportunity, + message: 'Oportunidad creada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateOpportunity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateOpportunitySchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de oportunidad invalidos', parseResult.error.errors); + } + + const dto: UpdateOpportunityDto = parseResult.data; + const opportunity = await opportunitiesService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: opportunity, + message: 'Oportunidad actualizada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async moveOpportunityStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = moveStageSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos invalidos', parseResult.error.errors); + } + + const opportunity = await opportunitiesService.moveStage(req.params.id, parseResult.data.stage_id, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: opportunity, + message: 'Oportunidad movida a nueva etapa', + }); + } catch (error) { + next(error); + } + } + + async markOpportunityWon(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const opportunity = await opportunitiesService.markWon(req.params.id, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: opportunity, + message: 'Oportunidad marcada como ganada', + }); + } catch (error) { + next(error); + } + } + + async markOpportunityLost(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = lostSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos invalidos', parseResult.error.errors); + } + + const opportunity = await opportunitiesService.markLost( + req.params.id, + parseResult.data.lost_reason_id, + parseResult.data.notes, + req.tenantId!, + req.user!.userId + ); + + res.json({ + success: true, + data: opportunity, + message: 'Oportunidad marcada como perdida', + }); + } catch (error) { + next(error); + } + } + + async createOpportunityQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const result = await opportunitiesService.createQuotation(req.params.id, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: result.opportunity, + quotation_id: result.quotation_id, + message: 'Cotizacion creada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async deleteOpportunity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await opportunitiesService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Oportunidad eliminada exitosamente' }); + } catch (error) { + next(error); + } + } + + async getPipeline(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const companyId = req.query.company_id as string | undefined; + const pipeline = await opportunitiesService.getPipeline(req.tenantId!, companyId); + + res.json({ + success: true, + data: pipeline, + }); + } catch (error) { + next(error); + } + } + + // ========== LEAD STAGES ========== + + async getLeadStages(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const includeInactive = req.query.include_inactive === 'true'; + const stages = await stagesService.getLeadStages(req.tenantId!, includeInactive); + res.json({ success: true, data: stages }); + } catch (error) { + next(error); + } + } + + async createLeadStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createStageSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de etapa invalidos', parseResult.error.errors); + } + + const dto: CreateLeadStageDto = parseResult.data; + const stage = await stagesService.createLeadStage(dto, req.tenantId!); + + res.status(201).json({ + success: true, + data: stage, + message: 'Etapa de lead creada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateLeadStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateStageSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de etapa invalidos', parseResult.error.errors); + } + + const dto: UpdateLeadStageDto = parseResult.data; + const stage = await stagesService.updateLeadStage(req.params.id, dto, req.tenantId!); + + res.json({ + success: true, + data: stage, + message: 'Etapa de lead actualizada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async deleteLeadStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await stagesService.deleteLeadStage(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Etapa de lead eliminada exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== OPPORTUNITY STAGES ========== + + async getOpportunityStages(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const includeInactive = req.query.include_inactive === 'true'; + const stages = await stagesService.getOpportunityStages(req.tenantId!, includeInactive); + res.json({ success: true, data: stages }); + } catch (error) { + next(error); + } + } + + async createOpportunityStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createStageSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de etapa invalidos', parseResult.error.errors); + } + + const dto: CreateOpportunityStageDto = parseResult.data; + const stage = await stagesService.createOpportunityStage(dto, req.tenantId!); + + res.status(201).json({ + success: true, + data: stage, + message: 'Etapa de oportunidad creada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateOpportunityStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateStageSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de etapa invalidos', parseResult.error.errors); + } + + const dto: UpdateOpportunityStageDto = parseResult.data; + const stage = await stagesService.updateOpportunityStage(req.params.id, dto, req.tenantId!); + + res.json({ + success: true, + data: stage, + message: 'Etapa de oportunidad actualizada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async deleteOpportunityStage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await stagesService.deleteOpportunityStage(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Etapa de oportunidad eliminada exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== LOST REASONS ========== + + async getLostReasons(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const includeInactive = req.query.include_inactive === 'true'; + const reasons = await stagesService.getLostReasons(req.tenantId!, includeInactive); + res.json({ success: true, data: reasons }); + } catch (error) { + next(error); + } + } + + async createLostReason(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createLostReasonSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de razon invalidos', parseResult.error.errors); + } + + const dto: CreateLostReasonDto = parseResult.data; + const reason = await stagesService.createLostReason(dto, req.tenantId!); + + res.status(201).json({ + success: true, + data: reason, + message: 'Razon de perdida creada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateLostReason(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateLostReasonSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de razon invalidos', parseResult.error.errors); + } + + const dto: UpdateLostReasonDto = parseResult.data; + const reason = await stagesService.updateLostReason(req.params.id, dto, req.tenantId!); + + res.json({ + success: true, + data: reason, + message: 'Razon de perdida actualizada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async deleteLostReason(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await stagesService.deleteLostReason(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Razon de perdida eliminada exitosamente' }); + } catch (error) { + next(error); + } + } +} + +export const crmController = new CrmController(); diff --git a/src/modules/crm/crm.routes.ts b/src/modules/crm/crm.routes.ts new file mode 100644 index 0000000..8445ca9 --- /dev/null +++ b/src/modules/crm/crm.routes.ts @@ -0,0 +1,126 @@ +import { Router } from 'express'; +import { crmController } from './crm.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// ========== LEADS ========== + +router.get('/leads', (req, res, next) => crmController.getLeads(req, res, next)); + +router.get('/leads/:id', (req, res, next) => crmController.getLead(req, res, next)); + +router.post('/leads', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + crmController.createLead(req, res, next) +); + +router.put('/leads/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + crmController.updateLead(req, res, next) +); + +router.post('/leads/:id/move', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + crmController.moveLeadStage(req, res, next) +); + +router.post('/leads/:id/convert', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + crmController.convertLead(req, res, next) +); + +router.post('/leads/:id/lost', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + crmController.markLeadLost(req, res, next) +); + +router.delete('/leads/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + crmController.deleteLead(req, res, next) +); + +// ========== OPPORTUNITIES ========== + +router.get('/opportunities', (req, res, next) => crmController.getOpportunities(req, res, next)); + +router.get('/opportunities/:id', (req, res, next) => crmController.getOpportunity(req, res, next)); + +router.post('/opportunities', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + crmController.createOpportunity(req, res, next) +); + +router.put('/opportunities/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + crmController.updateOpportunity(req, res, next) +); + +router.post('/opportunities/:id/move', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + crmController.moveOpportunityStage(req, res, next) +); + +router.post('/opportunities/:id/won', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + crmController.markOpportunityWon(req, res, next) +); + +router.post('/opportunities/:id/lost', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + crmController.markOpportunityLost(req, res, next) +); + +router.post('/opportunities/:id/quote', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + crmController.createOpportunityQuotation(req, res, next) +); + +router.delete('/opportunities/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + crmController.deleteOpportunity(req, res, next) +); + +// ========== PIPELINE ========== + +router.get('/pipeline', (req, res, next) => crmController.getPipeline(req, res, next)); + +// ========== LEAD STAGES ========== + +router.get('/lead-stages', (req, res, next) => crmController.getLeadStages(req, res, next)); + +router.post('/lead-stages', requireRoles('admin', 'super_admin'), (req, res, next) => + crmController.createLeadStage(req, res, next) +); + +router.put('/lead-stages/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + crmController.updateLeadStage(req, res, next) +); + +router.delete('/lead-stages/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + crmController.deleteLeadStage(req, res, next) +); + +// ========== OPPORTUNITY STAGES ========== + +router.get('/opportunity-stages', (req, res, next) => crmController.getOpportunityStages(req, res, next)); + +router.post('/opportunity-stages', requireRoles('admin', 'super_admin'), (req, res, next) => + crmController.createOpportunityStage(req, res, next) +); + +router.put('/opportunity-stages/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + crmController.updateOpportunityStage(req, res, next) +); + +router.delete('/opportunity-stages/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + crmController.deleteOpportunityStage(req, res, next) +); + +// ========== LOST REASONS ========== + +router.get('/lost-reasons', (req, res, next) => crmController.getLostReasons(req, res, next)); + +router.post('/lost-reasons', requireRoles('admin', 'super_admin'), (req, res, next) => + crmController.createLostReason(req, res, next) +); + +router.put('/lost-reasons/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + crmController.updateLostReason(req, res, next) +); + +router.delete('/lost-reasons/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + crmController.deleteLostReason(req, res, next) +); + +export default router; diff --git a/src/modules/crm/index.ts b/src/modules/crm/index.ts new file mode 100644 index 0000000..51e42d6 --- /dev/null +++ b/src/modules/crm/index.ts @@ -0,0 +1,5 @@ +export * from './leads.service.js'; +export * from './opportunities.service.js'; +export * from './stages.service.js'; +export * from './crm.controller.js'; +export { default as crmRoutes } from './crm.routes.js'; diff --git a/src/modules/crm/leads.service.ts b/src/modules/crm/leads.service.ts new file mode 100644 index 0000000..4dfeadc --- /dev/null +++ b/src/modules/crm/leads.service.ts @@ -0,0 +1,449 @@ +import { query, queryOne, getClient } from '../../config/database.js'; +import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js'; + +export type LeadStatus = 'new' | 'contacted' | 'qualified' | 'converted' | 'lost'; +export type LeadSource = 'website' | 'phone' | 'email' | 'referral' | 'social_media' | 'advertising' | 'event' | 'other'; + +export interface Lead { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + name: string; + ref?: string; + contact_name?: string; + email?: string; + phone?: string; + mobile?: string; + website?: string; + company_prospect_name?: string; + job_position?: string; + industry?: string; + employee_count?: string; + annual_revenue?: number; + street?: string; + city?: string; + state?: string; + zip?: string; + country?: string; + stage_id?: string; + stage_name?: string; + status: LeadStatus; + user_id?: string; + user_name?: string; + sales_team_id?: string; + source?: LeadSource; + priority: number; + probability: number; + expected_revenue?: number; + date_open?: Date; + date_closed?: Date; + date_deadline?: Date; + date_last_activity?: Date; + partner_id?: string; + opportunity_id?: string; + lost_reason_id?: string; + lost_reason_name?: string; + lost_notes?: string; + description?: string; + notes?: string; + tags?: string[]; + created_at: Date; +} + +export interface CreateLeadDto { + company_id: string; + name: string; + ref?: string; + contact_name?: string; + email?: string; + phone?: string; + mobile?: string; + website?: string; + company_prospect_name?: string; + job_position?: string; + industry?: string; + employee_count?: string; + annual_revenue?: number; + street?: string; + city?: string; + state?: string; + zip?: string; + country?: string; + stage_id?: string; + user_id?: string; + sales_team_id?: string; + source?: LeadSource; + priority?: number; + probability?: number; + expected_revenue?: number; + date_deadline?: string; + description?: string; + notes?: string; + tags?: string[]; +} + +export interface UpdateLeadDto { + name?: string; + ref?: string | null; + contact_name?: string | null; + email?: string | null; + phone?: string | null; + mobile?: string | null; + website?: string | null; + company_prospect_name?: string | null; + job_position?: string | null; + industry?: string | null; + employee_count?: string | null; + annual_revenue?: number | null; + street?: string | null; + city?: string | null; + state?: string | null; + zip?: string | null; + country?: string | null; + stage_id?: string | null; + user_id?: string | null; + sales_team_id?: string | null; + source?: LeadSource | null; + priority?: number; + probability?: number; + expected_revenue?: number | null; + date_deadline?: string | null; + description?: string | null; + notes?: string | null; + tags?: string[] | null; +} + +export interface LeadFilters { + company_id?: string; + status?: LeadStatus; + stage_id?: string; + user_id?: string; + source?: LeadSource; + priority?: number; + search?: string; + page?: number; + limit?: number; +} + +class LeadsService { + async findAll(tenantId: string, filters: LeadFilters = {}): Promise<{ data: Lead[]; total: number }> { + const { company_id, status, stage_id, user_id, source, priority, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE l.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND l.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (status) { + whereClause += ` AND l.status = $${paramIndex++}`; + params.push(status); + } + + if (stage_id) { + whereClause += ` AND l.stage_id = $${paramIndex++}`; + params.push(stage_id); + } + + if (user_id) { + whereClause += ` AND l.user_id = $${paramIndex++}`; + params.push(user_id); + } + + if (source) { + whereClause += ` AND l.source = $${paramIndex++}`; + params.push(source); + } + + if (priority !== undefined) { + whereClause += ` AND l.priority = $${paramIndex++}`; + params.push(priority); + } + + if (search) { + whereClause += ` AND (l.name ILIKE $${paramIndex} OR l.contact_name ILIKE $${paramIndex} OR l.email ILIKE $${paramIndex} OR l.company_name ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM crm.leads l ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT l.*, + c.name as company_org_name, + ls.name as stage_name, + u.email as user_email, + lr.name as lost_reason_name + FROM crm.leads l + LEFT JOIN auth.companies c ON l.company_id = c.id + LEFT JOIN crm.lead_stages ls ON l.stage_id = ls.id + LEFT JOIN auth.users u ON l.user_id = u.id + LEFT JOIN crm.lost_reasons lr ON l.lost_reason_id = lr.id + ${whereClause} + ORDER BY l.priority DESC, l.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const lead = await queryOne( + `SELECT l.*, + c.name as company_org_name, + ls.name as stage_name, + u.email as user_email, + lr.name as lost_reason_name + FROM crm.leads l + LEFT JOIN auth.companies c ON l.company_id = c.id + LEFT JOIN crm.lead_stages ls ON l.stage_id = ls.id + LEFT JOIN auth.users u ON l.user_id = u.id + LEFT JOIN crm.lost_reasons lr ON l.lost_reason_id = lr.id + WHERE l.id = $1 AND l.tenant_id = $2`, + [id, tenantId] + ); + + if (!lead) { + throw new NotFoundError('Lead no encontrado'); + } + + return lead; + } + + async create(dto: CreateLeadDto, tenantId: string, userId: string): Promise { + const lead = await queryOne( + `INSERT INTO crm.leads ( + tenant_id, company_id, name, ref, contact_name, email, phone, mobile, website, + company_name, job_position, industry, employee_count, annual_revenue, + street, city, state, zip, country, stage_id, user_id, sales_team_id, source, + priority, probability, expected_revenue, date_deadline, description, notes, tags, + date_open, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, + $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, CURRENT_TIMESTAMP, $31) + RETURNING *`, + [ + tenantId, dto.company_id, dto.name, dto.ref, dto.contact_name, dto.email, dto.phone, + dto.mobile, dto.website, dto.company_prospect_name, dto.job_position, dto.industry, + dto.employee_count, dto.annual_revenue, dto.street, dto.city, dto.state, dto.zip, + dto.country, dto.stage_id, dto.user_id, dto.sales_team_id, dto.source, + dto.priority || 0, dto.probability || 0, dto.expected_revenue, dto.date_deadline, + dto.description, dto.notes, dto.tags, userId + ] + ); + + return this.findById(lead!.id, tenantId); + } + + async update(id: string, dto: UpdateLeadDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status === 'converted' || existing.status === 'lost') { + throw new ValidationError('No se puede editar un lead convertido o perdido'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + const fieldsToUpdate = [ + 'name', 'ref', 'contact_name', 'email', 'phone', 'mobile', 'website', + 'company_prospect_name', 'job_position', 'industry', 'employee_count', 'annual_revenue', + 'street', 'city', 'state', 'zip', 'country', 'stage_id', 'user_id', 'sales_team_id', + 'source', 'priority', 'probability', 'expected_revenue', 'date_deadline', + 'description', 'notes', 'tags' + ]; + + for (const field of fieldsToUpdate) { + const key = field === 'company_prospect_name' ? 'company_name' : field; + if ((dto as any)[field] !== undefined) { + updateFields.push(`${key} = $${paramIndex++}`); + values.push((dto as any)[field]); + } + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`date_last_activity = CURRENT_TIMESTAMP`); + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + + values.push(id, tenantId); + + await query( + `UPDATE crm.leads SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async moveStage(id: string, stageId: string, tenantId: string, userId: string): Promise { + const lead = await this.findById(id, tenantId); + + if (lead.status === 'converted' || lead.status === 'lost') { + throw new ValidationError('No se puede mover un lead convertido o perdido'); + } + + await query( + `UPDATE crm.leads SET + stage_id = $1, + date_last_activity = CURRENT_TIMESTAMP, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $3 AND tenant_id = $4`, + [stageId, userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async convert(id: string, tenantId: string, userId: string): Promise<{ lead: Lead; opportunity_id: string }> { + const lead = await this.findById(id, tenantId); + + if (lead.status === 'converted') { + throw new ValidationError('El lead ya fue convertido'); + } + + if (lead.status === 'lost') { + throw new ValidationError('No se puede convertir un lead perdido'); + } + + const client = await getClient(); + + try { + await client.query('BEGIN'); + + // Create or get partner + let partnerId = lead.partner_id; + + if (!partnerId && lead.email) { + // Check if partner exists with same email + const existingPartner = await client.query( + `SELECT id FROM core.partners WHERE email = $1 AND tenant_id = $2`, + [lead.email, tenantId] + ); + + if (existingPartner.rows.length > 0) { + partnerId = existingPartner.rows[0].id; + } else { + // Create new partner + const partnerResult = await client.query( + `INSERT INTO core.partners (tenant_id, name, email, phone, mobile, is_customer, created_by) + VALUES ($1, $2, $3, $4, $5, TRUE, $6) + RETURNING id`, + [tenantId, lead.contact_name || lead.name, lead.email, lead.phone, lead.mobile, userId] + ); + partnerId = partnerResult.rows[0].id; + } + } + + if (!partnerId) { + throw new ValidationError('El lead debe tener un email o partner asociado para convertirse'); + } + + // Get default opportunity stage + const stageResult = await client.query( + `SELECT id FROM crm.opportunity_stages WHERE tenant_id = $1 ORDER BY sequence LIMIT 1`, + [tenantId] + ); + + const stageId = stageResult.rows[0]?.id || null; + + // Create opportunity + const opportunityResult = await client.query( + `INSERT INTO crm.opportunities ( + tenant_id, company_id, name, partner_id, contact_name, email, phone, + stage_id, user_id, sales_team_id, source, priority, probability, + expected_revenue, lead_id, description, notes, tags, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + RETURNING id`, + [ + tenantId, lead.company_id, lead.name, partnerId, lead.contact_name, lead.email, + lead.phone, stageId, lead.user_id, lead.sales_team_id, lead.source, lead.priority, + lead.probability, lead.expected_revenue, id, lead.description, lead.notes, lead.tags, userId + ] + ); + const opportunityId = opportunityResult.rows[0].id; + + // Update lead + await client.query( + `UPDATE crm.leads SET + status = 'converted', + partner_id = $1, + opportunity_id = $2, + date_closed = CURRENT_TIMESTAMP, + updated_by = $3, + updated_at = CURRENT_TIMESTAMP + WHERE id = $4`, + [partnerId, opportunityId, userId, id] + ); + + await client.query('COMMIT'); + + const updatedLead = await this.findById(id, tenantId); + + return { lead: updatedLead, opportunity_id: opportunityId }; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async markLost(id: string, lostReasonId: string, notes: string | undefined, tenantId: string, userId: string): Promise { + const lead = await this.findById(id, tenantId); + + if (lead.status === 'converted') { + throw new ValidationError('No se puede marcar como perdido un lead convertido'); + } + + if (lead.status === 'lost') { + throw new ValidationError('El lead ya esta marcado como perdido'); + } + + await query( + `UPDATE crm.leads SET + status = 'lost', + lost_reason_id = $1, + lost_notes = $2, + date_closed = CURRENT_TIMESTAMP, + updated_by = $3, + updated_at = CURRENT_TIMESTAMP + WHERE id = $4 AND tenant_id = $5`, + [lostReasonId, notes, userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + const lead = await this.findById(id, tenantId); + + if (lead.opportunity_id) { + throw new ConflictError('No se puede eliminar un lead que tiene una oportunidad asociada'); + } + + await query(`DELETE FROM crm.leads WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } +} + +export const leadsService = new LeadsService(); diff --git a/src/modules/crm/opportunities.service.ts b/src/modules/crm/opportunities.service.ts new file mode 100644 index 0000000..7d051a7 --- /dev/null +++ b/src/modules/crm/opportunities.service.ts @@ -0,0 +1,503 @@ +import { query, queryOne, getClient } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; +import { LeadSource } from './leads.service.js'; + +export type OpportunityStatus = 'open' | 'won' | 'lost'; + +export interface Opportunity { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + name: string; + ref?: string; + partner_id: string; + partner_name?: string; + contact_name?: string; + email?: string; + phone?: string; + stage_id?: string; + stage_name?: string; + status: OpportunityStatus; + user_id?: string; + user_name?: string; + sales_team_id?: string; + priority: number; + probability: number; + expected_revenue?: number; + recurring_revenue?: number; + recurring_plan?: string; + date_deadline?: Date; + date_closed?: Date; + date_last_activity?: Date; + lead_id?: string; + source?: LeadSource; + lost_reason_id?: string; + lost_reason_name?: string; + lost_notes?: string; + quotation_id?: string; + order_id?: string; + description?: string; + notes?: string; + tags?: string[]; + created_at: Date; +} + +export interface CreateOpportunityDto { + company_id: string; + name: string; + ref?: string; + partner_id: string; + contact_name?: string; + email?: string; + phone?: string; + stage_id?: string; + user_id?: string; + sales_team_id?: string; + priority?: number; + probability?: number; + expected_revenue?: number; + recurring_revenue?: number; + recurring_plan?: string; + date_deadline?: string; + source?: LeadSource; + description?: string; + notes?: string; + tags?: string[]; +} + +export interface UpdateOpportunityDto { + name?: string; + ref?: string | null; + partner_id?: string; + contact_name?: string | null; + email?: string | null; + phone?: string | null; + stage_id?: string | null; + user_id?: string | null; + sales_team_id?: string | null; + priority?: number; + probability?: number; + expected_revenue?: number | null; + recurring_revenue?: number | null; + recurring_plan?: string | null; + date_deadline?: string | null; + description?: string | null; + notes?: string | null; + tags?: string[] | null; +} + +export interface OpportunityFilters { + company_id?: string; + status?: OpportunityStatus; + stage_id?: string; + user_id?: string; + partner_id?: string; + priority?: number; + search?: string; + page?: number; + limit?: number; +} + +class OpportunitiesService { + async findAll(tenantId: string, filters: OpportunityFilters = {}): Promise<{ data: Opportunity[]; total: number }> { + const { company_id, status, stage_id, user_id, partner_id, priority, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE o.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND o.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (status) { + whereClause += ` AND o.status = $${paramIndex++}`; + params.push(status); + } + + if (stage_id) { + whereClause += ` AND o.stage_id = $${paramIndex++}`; + params.push(stage_id); + } + + if (user_id) { + whereClause += ` AND o.user_id = $${paramIndex++}`; + params.push(user_id); + } + + if (partner_id) { + whereClause += ` AND o.partner_id = $${paramIndex++}`; + params.push(partner_id); + } + + if (priority !== undefined) { + whereClause += ` AND o.priority = $${paramIndex++}`; + params.push(priority); + } + + if (search) { + whereClause += ` AND (o.name ILIKE $${paramIndex} OR o.contact_name ILIKE $${paramIndex} OR o.email ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM crm.opportunities o + LEFT JOIN core.partners p ON o.partner_id = p.id + ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT o.*, + c.name as company_org_name, + p.name as partner_name, + os.name as stage_name, + u.email as user_email, + lr.name as lost_reason_name + FROM crm.opportunities o + LEFT JOIN auth.companies c ON o.company_id = c.id + LEFT JOIN core.partners p ON o.partner_id = p.id + LEFT JOIN crm.opportunity_stages os ON o.stage_id = os.id + LEFT JOIN auth.users u ON o.user_id = u.id + LEFT JOIN crm.lost_reasons lr ON o.lost_reason_id = lr.id + ${whereClause} + ORDER BY o.priority DESC, o.expected_revenue DESC NULLS LAST, o.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const opportunity = await queryOne( + `SELECT o.*, + c.name as company_org_name, + p.name as partner_name, + os.name as stage_name, + u.email as user_email, + lr.name as lost_reason_name + FROM crm.opportunities o + LEFT JOIN auth.companies c ON o.company_id = c.id + LEFT JOIN core.partners p ON o.partner_id = p.id + LEFT JOIN crm.opportunity_stages os ON o.stage_id = os.id + LEFT JOIN auth.users u ON o.user_id = u.id + LEFT JOIN crm.lost_reasons lr ON o.lost_reason_id = lr.id + WHERE o.id = $1 AND o.tenant_id = $2`, + [id, tenantId] + ); + + if (!opportunity) { + throw new NotFoundError('Oportunidad no encontrada'); + } + + return opportunity; + } + + async create(dto: CreateOpportunityDto, tenantId: string, userId: string): Promise { + const opportunity = await queryOne( + `INSERT INTO crm.opportunities ( + tenant_id, company_id, name, ref, partner_id, contact_name, email, phone, + stage_id, user_id, sales_team_id, priority, probability, expected_revenue, + recurring_revenue, recurring_plan, date_deadline, source, description, notes, tags, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) + RETURNING *`, + [ + tenantId, dto.company_id, dto.name, dto.ref, dto.partner_id, dto.contact_name, + dto.email, dto.phone, dto.stage_id, dto.user_id, dto.sales_team_id, + dto.priority || 0, dto.probability || 0, dto.expected_revenue, dto.recurring_revenue, + dto.recurring_plan, dto.date_deadline, dto.source, dto.description, dto.notes, dto.tags, userId + ] + ); + + return this.findById(opportunity!.id, tenantId); + } + + async update(id: string, dto: UpdateOpportunityDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'open') { + throw new ValidationError('Solo se pueden editar oportunidades abiertas'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + const fieldsToUpdate = [ + 'name', 'ref', 'partner_id', 'contact_name', 'email', 'phone', 'stage_id', + 'user_id', 'sales_team_id', 'priority', 'probability', 'expected_revenue', + 'recurring_revenue', 'recurring_plan', 'date_deadline', 'description', 'notes', 'tags' + ]; + + for (const field of fieldsToUpdate) { + if ((dto as any)[field] !== undefined) { + updateFields.push(`${field} = $${paramIndex++}`); + values.push((dto as any)[field]); + } + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`date_last_activity = CURRENT_TIMESTAMP`); + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + + values.push(id, tenantId); + + await query( + `UPDATE crm.opportunities SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async moveStage(id: string, stageId: string, tenantId: string, userId: string): Promise { + const opportunity = await this.findById(id, tenantId); + + if (opportunity.status !== 'open') { + throw new ValidationError('Solo se pueden mover oportunidades abiertas'); + } + + // Get stage probability + const stage = await queryOne<{ probability: number; is_won: boolean }>( + `SELECT probability, is_won FROM crm.opportunity_stages WHERE id = $1 AND tenant_id = $2`, + [stageId, tenantId] + ); + + if (!stage) { + throw new NotFoundError('Etapa no encontrada'); + } + + await query( + `UPDATE crm.opportunities SET + stage_id = $1, + probability = $2, + date_last_activity = CURRENT_TIMESTAMP, + updated_by = $3, + updated_at = CURRENT_TIMESTAMP + WHERE id = $4 AND tenant_id = $5`, + [stageId, stage.probability, userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async markWon(id: string, tenantId: string, userId: string): Promise { + const opportunity = await this.findById(id, tenantId); + + if (opportunity.status !== 'open') { + throw new ValidationError('Solo se pueden marcar como ganadas oportunidades abiertas'); + } + + await query( + `UPDATE crm.opportunities SET + status = 'won', + probability = 100, + date_closed = CURRENT_TIMESTAMP, + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async markLost(id: string, lostReasonId: string, notes: string | undefined, tenantId: string, userId: string): Promise { + const opportunity = await this.findById(id, tenantId); + + if (opportunity.status !== 'open') { + throw new ValidationError('Solo se pueden marcar como perdidas oportunidades abiertas'); + } + + await query( + `UPDATE crm.opportunities SET + status = 'lost', + probability = 0, + lost_reason_id = $1, + lost_notes = $2, + date_closed = CURRENT_TIMESTAMP, + updated_by = $3, + updated_at = CURRENT_TIMESTAMP + WHERE id = $4 AND tenant_id = $5`, + [lostReasonId, notes, userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async createQuotation(id: string, tenantId: string, userId: string): Promise<{ opportunity: Opportunity; quotation_id: string }> { + const opportunity = await this.findById(id, tenantId); + + if (opportunity.status !== 'open') { + throw new ValidationError('Solo se pueden crear cotizaciones de oportunidades abiertas'); + } + + if (opportunity.quotation_id) { + throw new ValidationError('Esta oportunidad ya tiene una cotizacion asociada'); + } + + const client = await getClient(); + + try { + await client.query('BEGIN'); + + // Generate quotation name + const seqResult = await client.query( + `SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 3) AS INTEGER)), 0) + 1 as next_num + FROM sales.quotations WHERE tenant_id = $1 AND name LIKE 'SO%'`, + [tenantId] + ); + const nextNum = seqResult.rows[0]?.next_num || 1; + const quotationName = `SO${String(nextNum).padStart(6, '0')}`; + + // Get default currency + const currencyResult = await client.query( + `SELECT id FROM core.currencies WHERE code = 'MXN' AND tenant_id = $1 LIMIT 1`, + [tenantId] + ); + const currencyId = currencyResult.rows[0]?.id; + + if (!currencyId) { + throw new ValidationError('No se encontro una moneda configurada'); + } + + // Create quotation + const quotationResult = await client.query( + `INSERT INTO sales.quotations ( + tenant_id, company_id, name, partner_id, quotation_date, validity_date, + currency_id, user_id, notes, created_by + ) + VALUES ($1, $2, $3, $4, CURRENT_DATE, CURRENT_DATE + INTERVAL '30 days', $5, $6, $7, $8) + RETURNING id`, + [ + tenantId, opportunity.company_id, quotationName, opportunity.partner_id, + currencyId, userId, opportunity.description, userId + ] + ); + const quotationId = quotationResult.rows[0].id; + + // Update opportunity + await client.query( + `UPDATE crm.opportunities SET + quotation_id = $1, + date_last_activity = CURRENT_TIMESTAMP, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $3`, + [quotationId, userId, id] + ); + + await client.query('COMMIT'); + + const updatedOpportunity = await this.findById(id, tenantId); + + return { opportunity: updatedOpportunity, quotation_id: quotationId }; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async delete(id: string, tenantId: string): Promise { + const opportunity = await this.findById(id, tenantId); + + if (opportunity.quotation_id || opportunity.order_id) { + throw new ValidationError('No se puede eliminar una oportunidad con cotizacion u orden asociada'); + } + + // Update lead if exists + if (opportunity.lead_id) { + await query( + `UPDATE crm.leads SET opportunity_id = NULL WHERE id = $1`, + [opportunity.lead_id] + ); + } + + await query(`DELETE FROM crm.opportunities WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } + + // Pipeline view - grouped by stage + async getPipeline(tenantId: string, companyId?: string): Promise<{ stages: any[]; totals: any }> { + let whereClause = 'WHERE o.tenant_id = $1 AND o.status = $2'; + const params: any[] = [tenantId, 'open']; + + if (companyId) { + whereClause += ` AND o.company_id = $3`; + params.push(companyId); + } + + const stages = await query<{ id: string; name: string; sequence: number; probability: number }>( + `SELECT id, name, sequence, probability + FROM crm.opportunity_stages + WHERE tenant_id = $1 AND active = TRUE + ORDER BY sequence`, + [tenantId] + ); + + const opportunities = await query( + `SELECT o.id, o.name, o.partner_id, p.name as partner_name, + o.stage_id, o.expected_revenue, o.probability, o.priority, + o.date_deadline, o.user_id + FROM crm.opportunities o + LEFT JOIN core.partners p ON o.partner_id = p.id + ${whereClause} + ORDER BY o.priority DESC, o.expected_revenue DESC`, + params + ); + + // Group opportunities by stage + const pipelineStages = stages.map(stage => ({ + ...stage, + opportunities: opportunities.filter((opp: any) => opp.stage_id === stage.id), + count: opportunities.filter((opp: any) => opp.stage_id === stage.id).length, + total_revenue: opportunities + .filter((opp: any) => opp.stage_id === stage.id) + .reduce((sum: number, opp: any) => sum + (parseFloat(opp.expected_revenue) || 0), 0) + })); + + // Add "No stage" for opportunities without stage + const noStageOpps = opportunities.filter((opp: any) => !opp.stage_id); + if (noStageOpps.length > 0) { + pipelineStages.unshift({ + id: null as unknown as string, + name: 'Sin etapa', + sequence: 0, + probability: 0, + opportunities: noStageOpps, + count: noStageOpps.length, + total_revenue: noStageOpps.reduce((sum: number, opp: any) => sum + (parseFloat(opp.expected_revenue) || 0), 0) + }); + } + + const totals = { + total_opportunities: opportunities.length, + total_expected_revenue: opportunities.reduce((sum: number, opp: any) => sum + (parseFloat(opp.expected_revenue) || 0), 0), + weighted_revenue: opportunities.reduce((sum: number, opp: any) => { + const revenue = parseFloat(opp.expected_revenue) || 0; + const probability = parseFloat(opp.probability) || 0; + return sum + (revenue * probability / 100); + }, 0) + }; + + return { stages: pipelineStages, totals }; + } +} + +export const opportunitiesService = new OpportunitiesService(); diff --git a/src/modules/crm/stages.service.ts b/src/modules/crm/stages.service.ts new file mode 100644 index 0000000..92f01f9 --- /dev/null +++ b/src/modules/crm/stages.service.ts @@ -0,0 +1,435 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ConflictError } from '../../shared/errors/index.js'; + +// ========== LEAD STAGES ========== + +export interface LeadStage { + id: string; + tenant_id: string; + name: string; + sequence: number; + is_won: boolean; + probability: number; + requirements?: string; + active: boolean; + created_at: Date; +} + +export interface CreateLeadStageDto { + name: string; + sequence?: number; + is_won?: boolean; + probability?: number; + requirements?: string; +} + +export interface UpdateLeadStageDto { + name?: string; + sequence?: number; + is_won?: boolean; + probability?: number; + requirements?: string | null; + active?: boolean; +} + +// ========== OPPORTUNITY STAGES ========== + +export interface OpportunityStage { + id: string; + tenant_id: string; + name: string; + sequence: number; + is_won: boolean; + probability: number; + requirements?: string; + active: boolean; + created_at: Date; +} + +export interface CreateOpportunityStageDto { + name: string; + sequence?: number; + is_won?: boolean; + probability?: number; + requirements?: string; +} + +export interface UpdateOpportunityStageDto { + name?: string; + sequence?: number; + is_won?: boolean; + probability?: number; + requirements?: string | null; + active?: boolean; +} + +// ========== LOST REASONS ========== + +export interface LostReason { + id: string; + tenant_id: string; + name: string; + description?: string; + active: boolean; + created_at: Date; +} + +export interface CreateLostReasonDto { + name: string; + description?: string; +} + +export interface UpdateLostReasonDto { + name?: string; + description?: string | null; + active?: boolean; +} + +class StagesService { + // ========== LEAD STAGES ========== + + async getLeadStages(tenantId: string, includeInactive = false): Promise { + let whereClause = 'WHERE tenant_id = $1'; + if (!includeInactive) { + whereClause += ' AND active = TRUE'; + } + + return query( + `SELECT * FROM crm.lead_stages ${whereClause} ORDER BY sequence`, + [tenantId] + ); + } + + async getLeadStageById(id: string, tenantId: string): Promise { + const stage = await queryOne( + `SELECT * FROM crm.lead_stages WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + if (!stage) { + throw new NotFoundError('Etapa de lead no encontrada'); + } + + return stage; + } + + async createLeadStage(dto: CreateLeadStageDto, tenantId: string): Promise { + // Check unique name + const existing = await queryOne( + `SELECT id FROM crm.lead_stages WHERE name = $1 AND tenant_id = $2`, + [dto.name, tenantId] + ); + + if (existing) { + throw new ConflictError('Ya existe una etapa con ese nombre'); + } + + const stage = await queryOne( + `INSERT INTO crm.lead_stages (tenant_id, name, sequence, is_won, probability, requirements) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [tenantId, dto.name, dto.sequence || 10, dto.is_won || false, dto.probability || 0, dto.requirements] + ); + + return stage!; + } + + async updateLeadStage(id: string, dto: UpdateLeadStageDto, tenantId: string): Promise { + await this.getLeadStageById(id, tenantId); + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.name !== undefined) { + // Check unique name + const existing = await queryOne( + `SELECT id FROM crm.lead_stages WHERE name = $1 AND tenant_id = $2 AND id != $3`, + [dto.name, tenantId, id] + ); + if (existing) { + throw new ConflictError('Ya existe una etapa con ese nombre'); + } + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.sequence !== undefined) { + updateFields.push(`sequence = $${paramIndex++}`); + values.push(dto.sequence); + } + if (dto.is_won !== undefined) { + updateFields.push(`is_won = $${paramIndex++}`); + values.push(dto.is_won); + } + if (dto.probability !== undefined) { + updateFields.push(`probability = $${paramIndex++}`); + values.push(dto.probability); + } + if (dto.requirements !== undefined) { + updateFields.push(`requirements = $${paramIndex++}`); + values.push(dto.requirements); + } + if (dto.active !== undefined) { + updateFields.push(`active = $${paramIndex++}`); + values.push(dto.active); + } + + if (updateFields.length === 0) { + return this.getLeadStageById(id, tenantId); + } + + values.push(id, tenantId); + + await query( + `UPDATE crm.lead_stages SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.getLeadStageById(id, tenantId); + } + + async deleteLeadStage(id: string, tenantId: string): Promise { + await this.getLeadStageById(id, tenantId); + + // Check if stage is in use + const inUse = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM crm.leads WHERE stage_id = $1`, + [id] + ); + + if (parseInt(inUse?.count || '0') > 0) { + throw new ConflictError('No se puede eliminar una etapa que tiene leads asociados'); + } + + await query(`DELETE FROM crm.lead_stages WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } + + // ========== OPPORTUNITY STAGES ========== + + async getOpportunityStages(tenantId: string, includeInactive = false): Promise { + let whereClause = 'WHERE tenant_id = $1'; + if (!includeInactive) { + whereClause += ' AND active = TRUE'; + } + + return query( + `SELECT * FROM crm.opportunity_stages ${whereClause} ORDER BY sequence`, + [tenantId] + ); + } + + async getOpportunityStageById(id: string, tenantId: string): Promise { + const stage = await queryOne( + `SELECT * FROM crm.opportunity_stages WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + if (!stage) { + throw new NotFoundError('Etapa de oportunidad no encontrada'); + } + + return stage; + } + + async createOpportunityStage(dto: CreateOpportunityStageDto, tenantId: string): Promise { + // Check unique name + const existing = await queryOne( + `SELECT id FROM crm.opportunity_stages WHERE name = $1 AND tenant_id = $2`, + [dto.name, tenantId] + ); + + if (existing) { + throw new ConflictError('Ya existe una etapa con ese nombre'); + } + + const stage = await queryOne( + `INSERT INTO crm.opportunity_stages (tenant_id, name, sequence, is_won, probability, requirements) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [tenantId, dto.name, dto.sequence || 10, dto.is_won || false, dto.probability || 0, dto.requirements] + ); + + return stage!; + } + + async updateOpportunityStage(id: string, dto: UpdateOpportunityStageDto, tenantId: string): Promise { + await this.getOpportunityStageById(id, tenantId); + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.name !== undefined) { + const existing = await queryOne( + `SELECT id FROM crm.opportunity_stages WHERE name = $1 AND tenant_id = $2 AND id != $3`, + [dto.name, tenantId, id] + ); + if (existing) { + throw new ConflictError('Ya existe una etapa con ese nombre'); + } + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.sequence !== undefined) { + updateFields.push(`sequence = $${paramIndex++}`); + values.push(dto.sequence); + } + if (dto.is_won !== undefined) { + updateFields.push(`is_won = $${paramIndex++}`); + values.push(dto.is_won); + } + if (dto.probability !== undefined) { + updateFields.push(`probability = $${paramIndex++}`); + values.push(dto.probability); + } + if (dto.requirements !== undefined) { + updateFields.push(`requirements = $${paramIndex++}`); + values.push(dto.requirements); + } + if (dto.active !== undefined) { + updateFields.push(`active = $${paramIndex++}`); + values.push(dto.active); + } + + if (updateFields.length === 0) { + return this.getOpportunityStageById(id, tenantId); + } + + values.push(id, tenantId); + + await query( + `UPDATE crm.opportunity_stages SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.getOpportunityStageById(id, tenantId); + } + + async deleteOpportunityStage(id: string, tenantId: string): Promise { + await this.getOpportunityStageById(id, tenantId); + + const inUse = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM crm.opportunities WHERE stage_id = $1`, + [id] + ); + + if (parseInt(inUse?.count || '0') > 0) { + throw new ConflictError('No se puede eliminar una etapa que tiene oportunidades asociadas'); + } + + await query(`DELETE FROM crm.opportunity_stages WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } + + // ========== LOST REASONS ========== + + async getLostReasons(tenantId: string, includeInactive = false): Promise { + let whereClause = 'WHERE tenant_id = $1'; + if (!includeInactive) { + whereClause += ' AND active = TRUE'; + } + + return query( + `SELECT * FROM crm.lost_reasons ${whereClause} ORDER BY name`, + [tenantId] + ); + } + + async getLostReasonById(id: string, tenantId: string): Promise { + const reason = await queryOne( + `SELECT * FROM crm.lost_reasons WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + if (!reason) { + throw new NotFoundError('Razon de perdida no encontrada'); + } + + return reason; + } + + async createLostReason(dto: CreateLostReasonDto, tenantId: string): Promise { + const existing = await queryOne( + `SELECT id FROM crm.lost_reasons WHERE name = $1 AND tenant_id = $2`, + [dto.name, tenantId] + ); + + if (existing) { + throw new ConflictError('Ya existe una razon con ese nombre'); + } + + const reason = await queryOne( + `INSERT INTO crm.lost_reasons (tenant_id, name, description) + VALUES ($1, $2, $3) + RETURNING *`, + [tenantId, dto.name, dto.description] + ); + + return reason!; + } + + async updateLostReason(id: string, dto: UpdateLostReasonDto, tenantId: string): Promise { + await this.getLostReasonById(id, tenantId); + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.name !== undefined) { + const existing = await queryOne( + `SELECT id FROM crm.lost_reasons WHERE name = $1 AND tenant_id = $2 AND id != $3`, + [dto.name, tenantId, id] + ); + if (existing) { + throw new ConflictError('Ya existe una razon con ese nombre'); + } + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.description !== undefined) { + updateFields.push(`description = $${paramIndex++}`); + values.push(dto.description); + } + if (dto.active !== undefined) { + updateFields.push(`active = $${paramIndex++}`); + values.push(dto.active); + } + + if (updateFields.length === 0) { + return this.getLostReasonById(id, tenantId); + } + + values.push(id, tenantId); + + await query( + `UPDATE crm.lost_reasons SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.getLostReasonById(id, tenantId); + } + + async deleteLostReason(id: string, tenantId: string): Promise { + await this.getLostReasonById(id, tenantId); + + const inUseLeads = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM crm.leads WHERE lost_reason_id = $1`, + [id] + ); + + const inUseOpps = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM crm.opportunities WHERE lost_reason_id = $1`, + [id] + ); + + if (parseInt(inUseLeads?.count || '0') > 0 || parseInt(inUseOpps?.count || '0') > 0) { + throw new ConflictError('No se puede eliminar una razon que esta en uso'); + } + + await query(`DELETE FROM crm.lost_reasons WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } +} + +export const stagesService = new StagesService(); diff --git a/src/modules/financial/MIGRATION_GUIDE.md b/src/modules/financial/MIGRATION_GUIDE.md new file mode 100644 index 0000000..34060a8 --- /dev/null +++ b/src/modules/financial/MIGRATION_GUIDE.md @@ -0,0 +1,612 @@ +# Financial Module TypeORM Migration Guide + +## Overview + +This guide documents the migration of the Financial module from raw SQL queries to TypeORM. The migration maintains backwards compatibility while introducing modern ORM patterns. + +## Completed Tasks + +### 1. Entity Creation ✅ + +All TypeORM entities have been created in `/src/modules/financial/entities/`: + +- **account-type.entity.ts** - Chart of account types catalog +- **account.entity.ts** - Accounts with hierarchy support +- **journal.entity.ts** - Accounting journals +- **journal-entry.entity.ts** - Journal entries (header) +- **journal-entry-line.entity.ts** - Journal entry lines (detail) +- **invoice.entity.ts** - Customer and supplier invoices +- **invoice-line.entity.ts** - Invoice line items +- **payment.entity.ts** - Payment transactions +- **tax.entity.ts** - Tax configuration +- **fiscal-year.entity.ts** - Fiscal years +- **fiscal-period.entity.ts** - Fiscal periods (months/quarters) +- **index.ts** - Barrel export file + +### 2. Entity Registration ✅ + +All financial entities have been registered in `/src/config/typeorm.ts`: +- Import statements added +- Entities added to the `entities` array in AppDataSource configuration + +### 3. Service Refactoring ✅ + +#### accounts.service.ts - COMPLETED + +The accounts service has been fully migrated to TypeORM with the following features: + +**Key Changes:** +- Uses `Repository` and `Repository` +- Implements QueryBuilder for complex queries with joins +- Supports both snake_case (DB) and camelCase (TS) through decorators +- Maintains all original functionality including: + - Account hierarchy with cycle detection + - Soft delete with validation + - Balance calculations + - Full CRUD operations + +**Pattern to Follow:** +```typescript +import { Repository, IsNull } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { Entity } from './entities/index.js'; + +class MyService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(Entity); + } + + async findAll(tenantId: string, filters = {}) { + const queryBuilder = this.repository + .createQueryBuilder('alias') + .leftJoin('alias.relation', 'relation') + .addSelect(['relation.field']) + .where('alias.tenantId = :tenantId', { tenantId }); + + // Apply filters + // Get count and results + return { data, total }; + } +} +``` + +## Remaining Tasks + +### Services to Migrate + +#### 1. journals.service.ts - PRIORITY HIGH + +**Current State:** Uses raw SQL queries +**Target Pattern:** Same as accounts.service.ts + +**Migration Steps:** +1. Import Journal entity and Repository +2. Replace all `query()` and `queryOne()` calls with Repository methods +3. Use QueryBuilder for complex queries with joins (company, account, currency) +4. Update return types to use entity types instead of interfaces +5. Maintain validation logic for: + - Unique code per company + - Journal entry existence check before delete +6. Test endpoints thoroughly + +**Key Relationships:** +- Journal → Company (ManyToOne) +- Journal → Account (default account, ManyToOne, optional) + +--- + +#### 2. taxes.service.ts - PRIORITY HIGH + +**Current State:** Uses raw SQL queries +**Special Feature:** Tax calculation logic + +**Migration Steps:** +1. Import Tax entity and Repository +2. Migrate CRUD operations to Repository +3. **IMPORTANT:** Keep `calculateTaxes()` and `calculateDocumentTaxes()` logic intact +4. These calculation methods can still use raw queries if needed +5. Update filters to use QueryBuilder + +**Tax Calculation Logic:** +- Located in lines 224-354 of current service +- Critical for invoice and payment processing +- DO NOT modify calculation algorithms +- Only update data access layer + +--- + +#### 3. journal-entries.service.ts - PRIORITY MEDIUM + +**Current State:** Uses raw SQL with transactions +**Complexity:** HIGH - Multi-table operations + +**Migration Steps:** +1. Import JournalEntry, JournalEntryLine entities +2. Use TypeORM QueryRunner for transactions: +```typescript +const queryRunner = AppDataSource.createQueryRunner(); +await queryRunner.connect(); +await queryRunner.startTransaction(); + +try { + // Operations + await queryRunner.commitTransaction(); +} catch (error) { + await queryRunner.rollbackTransaction(); + throw error; +} finally { + await queryRunner.release(); +} +``` + +3. **Double-Entry Balance Validation:** + - Keep validation logic lines 172-177 + - Validate debit = credit before saving +4. Use cascade operations for lines: + - `cascade: true` is already set in entity + - Can save entry with lines in single operation + +**Critical Features:** +- Transaction management (BEGIN/COMMIT/ROLLBACK) +- Balance validation (debits must equal credits) +- Status transitions (draft → posted → cancelled) +- Fiscal period validation + +--- + +#### 4. invoices.service.ts - PRIORITY MEDIUM + +**Current State:** Uses raw SQL with complex line management +**Complexity:** HIGH - Invoice lines, tax calculations + +**Migration Steps:** +1. Import Invoice, InvoiceLine entities +2. Use transactions for multi-table operations +3. **Tax Integration:** + - Line 331-340: Uses taxesService.calculateTaxes() + - Keep this integration intact + - Only migrate data access +4. **Amount Calculations:** + - updateTotals() method (lines 525-543) + - Can use QueryBuilder aggregation or raw SQL +5. **Number Generation:** + - Lines 472-478: Sequential invoice numbering + - Keep this logic, migrate to Repository + +**Relationships:** +- Invoice → Company +- Invoice → Journal (optional) +- Invoice → JournalEntry (optional, for accounting integration) +- Invoice → InvoiceLine[] (one-to-many, cascade) +- InvoiceLine → Account (optional) + +--- + +#### 5. payments.service.ts - PRIORITY MEDIUM + +**Current State:** Uses raw SQL with invoice reconciliation +**Complexity:** MEDIUM-HIGH - Payment-Invoice linking + +**Migration Steps:** +1. Import Payment entity +2. **Payment-Invoice Junction:** + - Table: `financial.payment_invoice` + - Not modeled as entity (junction table) + - Can use raw SQL for this or create entity +3. Use transactions for reconciliation +4. **Invoice Status Updates:** + - Lines 373-380: Updates invoice amounts + - Must coordinate with Invoice entity + +**Critical Logic:** +- Reconciliation workflow (lines 314-401) +- Invoice amount updates +- Transaction rollback on errors + +--- + +#### 6. fiscalPeriods.service.ts - PRIORITY LOW + +**Current State:** Uses raw SQL + database functions +**Complexity:** MEDIUM - Database function calls + +**Migration Steps:** +1. Import FiscalYear, FiscalPeriod entities +2. Basic CRUD can use Repository +3. **Database Functions:** + - Line 242: `financial.close_fiscal_period()` + - Line 265: `financial.reopen_fiscal_period()` + - Keep these as raw SQL calls: + ```typescript + await this.repository.query( + 'SELECT * FROM financial.close_fiscal_period($1, $2)', + [periodId, userId] + ); + ``` +4. **Date Overlap Validation:** + - Lines 102-107, 207-212 + - Use QueryBuilder with date range checks + +--- + +## Controller Updates + +### Accept Both snake_case and camelCase + +The controller currently only accepts snake_case. Update to support both: + +**Current:** +```typescript +const createAccountSchema = z.object({ + company_id: z.string().uuid(), + code: z.string(), + // ... +}); +``` + +**Updated:** +```typescript +const createAccountSchema = z.object({ + companyId: z.string().uuid().optional(), + company_id: z.string().uuid().optional(), + code: z.string(), + // ... +}).refine( + (data) => data.companyId || data.company_id, + { message: "Either companyId or company_id is required" } +); + +// Then normalize before service call: +const dto = { + companyId: parseResult.data.companyId || parseResult.data.company_id, + // ... rest of fields +}; +``` + +**Simpler Approach:** +Transform incoming data before validation: +```typescript +// Add utility function +function toCamelCase(obj: any): any { + const camelObj: any = {}; + for (const key in obj) { + const camelKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase()); + camelObj[camelKey] = obj[key]; + } + return camelObj; +} + +// Use in controller +const normalizedBody = toCamelCase(req.body); +const parseResult = createAccountSchema.safeParse(normalizedBody); +``` + +--- + +## Migration Patterns + +### 1. Repository Setup + +```typescript +import { Repository, IsNull } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { MyEntity } from './entities/index.js'; + +class MyService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(MyEntity); + } +} +``` + +### 2. Simple Find Operations + +**Before (Raw SQL):** +```typescript +const result = await queryOne( + `SELECT * FROM schema.table WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] +); +``` + +**After (TypeORM):** +```typescript +const result = await this.repository.findOne({ + where: { id, tenantId, deletedAt: IsNull() } +}); +``` + +### 3. Complex Queries with Joins + +**Before:** +```typescript +const data = await query( + `SELECT e.*, r.name as relation_name + FROM schema.entities e + LEFT JOIN schema.relations r ON e.relation_id = r.id + WHERE e.tenant_id = $1`, + [tenantId] +); +``` + +**After:** +```typescript +const data = await this.repository + .createQueryBuilder('entity') + .leftJoin('entity.relation', 'relation') + .addSelect(['relation.name']) + .where('entity.tenantId = :tenantId', { tenantId }) + .getMany(); +``` + +### 4. Transactions + +**Before:** +```typescript +const client = await getClient(); +try { + await client.query('BEGIN'); + // operations + await client.query('COMMIT'); +} catch (error) { + await client.query('ROLLBACK'); + throw error; +} finally { + client.release(); +} +``` + +**After:** +```typescript +const queryRunner = AppDataSource.createQueryRunner(); +await queryRunner.connect(); +await queryRunner.startTransaction(); + +try { + // operations using queryRunner.manager + await queryRunner.manager.save(entity); + await queryRunner.commitTransaction(); +} catch (error) { + await queryRunner.rollbackTransaction(); + throw error; +} finally { + await queryRunner.release(); +} +``` + +### 5. Soft Deletes + +**Pattern:** +```typescript +await this.repository.update( + { id, tenantId }, + { + deletedAt: new Date(), + deletedBy: userId, + } +); +``` + +### 6. Pagination + +```typescript +const skip = (page - 1) * limit; + +const [data, total] = await this.repository.findAndCount({ + where: { tenantId, deletedAt: IsNull() }, + skip, + take: limit, + order: { createdAt: 'DESC' }, +}); + +return { data, total }; +``` + +--- + +## Testing Strategy + +### 1. Unit Tests + +For each refactored service: + +```typescript +describe('AccountsService', () => { + let service: AccountsService; + let repository: Repository; + + beforeEach(() => { + repository = AppDataSource.getRepository(Account); + service = new AccountsService(); + }); + + it('should create account with valid data', async () => { + const dto = { /* ... */ }; + const result = await service.create(dto, tenantId, userId); + expect(result.id).toBeDefined(); + expect(result.code).toBe(dto.code); + }); +}); +``` + +### 2. Integration Tests + +Test with actual database: + +```bash +# Run tests +npm test src/modules/financial/__tests__/ +``` + +### 3. API Tests + +Test HTTP endpoints: + +```bash +# Test accounts endpoints +curl -X GET http://localhost:3000/api/financial/accounts?companyId=xxx +curl -X POST http://localhost:3000/api/financial/accounts -d '{"companyId":"xxx",...}' +``` + +--- + +## Rollback Plan + +If migration causes issues: + +1. **Restore Old Services:** +```bash +cd src/modules/financial +mv accounts.service.ts accounts.service.new.ts +mv accounts.service.old.ts accounts.service.ts +``` + +2. **Remove Entity Imports:** +Edit `/src/config/typeorm.ts` and remove financial entity imports + +3. **Restart Application:** +```bash +npm run dev +``` + +--- + +## Database Schema Notes + +### Schema: `financial` + +All tables use the `financial` schema as specified in entities. + +### Important Columns: + +- **tenant_id**: Multi-tenancy isolation (UUID, NOT NULL) +- **company_id**: Company isolation (UUID, NOT NULL) +- **deleted_at**: Soft delete timestamp (NULL = active) +- **created_at**: Audit timestamp +- **created_by**: User ID who created (UUID) +- **updated_at**: Audit timestamp +- **updated_by**: User ID who updated (UUID) + +### Decimal Precision: + +- **Amounts**: DECIMAL(15, 2) - invoices, payments +- **Quantity**: DECIMAL(15, 4) - invoice lines +- **Tax Rate**: DECIMAL(5, 2) - tax percentage + +--- + +## Common Issues and Solutions + +### Issue 1: Column Name Mismatch + +**Error:** `column "companyId" does not exist` + +**Solution:** Entity decorators map camelCase to snake_case: +```typescript +@Column({ name: 'company_id' }) +companyId: string; +``` + +### Issue 2: Soft Deletes Not Working + +**Solution:** Always include `deletedAt: IsNull()` in where clauses: +```typescript +where: { id, tenantId, deletedAt: IsNull() } +``` + +### Issue 3: Transaction Not Rolling Back + +**Solution:** Always use try-catch-finally with queryRunner: +```typescript +finally { + await queryRunner.release(); // MUST release +} +``` + +### Issue 4: Relations Not Loading + +**Solution:** Use leftJoin or relations option: +```typescript +// Option 1: Query Builder +.leftJoin('entity.relation', 'relation') +.addSelect(['relation.field']) + +// Option 2: Find options +findOne({ + where: { id }, + relations: ['relation'], +}) +``` + +--- + +## Performance Considerations + +### 1. Query Optimization + +- Use `leftJoin` + `addSelect` instead of `relations` option for better control +- Add indexes on frequently queried columns (already in entities) +- Use pagination for large result sets + +### 2. Connection Pooling + +TypeORM pool configuration (in typeorm.ts): +```typescript +extra: { + max: 10, // Conservative to not compete with pg pool + min: 2, + idleTimeoutMillis: 30000, +} +``` + +### 3. Caching + +Currently disabled: +```typescript +cache: false +``` + +Can enable later for read-heavy operations. + +--- + +## Next Steps + +1. **Complete service migrations** in this order: + - taxes.service.ts (High priority, simple) + - journals.service.ts (High priority, simple) + - journal-entries.service.ts (Medium, complex transactions) + - invoices.service.ts (Medium, tax integration) + - payments.service.ts (Medium, reconciliation) + - fiscalPeriods.service.ts (Low, DB functions) + +2. **Update controller** to accept both snake_case and camelCase + +3. **Write tests** for each migrated service + +4. **Update API documentation** to reflect camelCase support + +5. **Monitor performance** after deployment + +--- + +## Support and Questions + +For questions about this migration: +- Check existing patterns in `accounts.service.ts` +- Review TypeORM documentation: https://typeorm.io +- Check entity definitions in `/entities/` folder + +--- + +## Changelog + +### 2024-12-14 +- Created all TypeORM entities +- Registered entities in AppDataSource +- Completed accounts.service.ts migration +- Created this migration guide diff --git a/src/modules/financial/accounts.service.old.ts b/src/modules/financial/accounts.service.old.ts new file mode 100644 index 0000000..14d2fb5 --- /dev/null +++ b/src/modules/financial/accounts.service.old.ts @@ -0,0 +1,330 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ConflictError } from '../../shared/errors/index.js'; + +export type AccountType = 'asset' | 'liability' | 'equity' | 'income' | 'expense'; + +export interface AccountTypeEntity { + id: string; + code: string; + name: string; + account_type: AccountType; + description?: string; +} + +export interface Account { + id: string; + tenant_id: string; + company_id: string; + code: string; + name: string; + account_type_id: string; + account_type_name?: string; + account_type_code?: string; + parent_id?: string; + parent_name?: string; + currency_id?: string; + currency_code?: string; + is_reconcilable: boolean; + is_deprecated: boolean; + notes?: string; + created_at: Date; +} + +export interface CreateAccountDto { + company_id: string; + code: string; + name: string; + account_type_id: string; + parent_id?: string; + currency_id?: string; + is_reconcilable?: boolean; + notes?: string; +} + +export interface UpdateAccountDto { + name?: string; + parent_id?: string | null; + currency_id?: string | null; + is_reconcilable?: boolean; + is_deprecated?: boolean; + notes?: string | null; +} + +export interface AccountFilters { + company_id?: string; + account_type_id?: string; + parent_id?: string; + is_deprecated?: boolean; + search?: string; + page?: number; + limit?: number; +} + +class AccountsService { + // Account Types (catalog) + async findAllAccountTypes(): Promise { + return query( + `SELECT * FROM financial.account_types ORDER BY code` + ); + } + + async findAccountTypeById(id: string): Promise { + const accountType = await queryOne( + `SELECT * FROM financial.account_types WHERE id = $1`, + [id] + ); + if (!accountType) { + throw new NotFoundError('Tipo de cuenta no encontrado'); + } + return accountType; + } + + // Accounts + async findAll(tenantId: string, filters: AccountFilters = {}): Promise<{ data: Account[]; total: number }> { + const { company_id, account_type_id, parent_id, is_deprecated, search, page = 1, limit = 50 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE a.tenant_id = $1 AND a.deleted_at IS NULL'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND a.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (account_type_id) { + whereClause += ` AND a.account_type_id = $${paramIndex++}`; + params.push(account_type_id); + } + + if (parent_id !== undefined) { + if (parent_id === null || parent_id === 'null') { + whereClause += ' AND a.parent_id IS NULL'; + } else { + whereClause += ` AND a.parent_id = $${paramIndex++}`; + params.push(parent_id); + } + } + + if (is_deprecated !== undefined) { + whereClause += ` AND a.is_deprecated = $${paramIndex++}`; + params.push(is_deprecated); + } + + if (search) { + whereClause += ` AND (a.code ILIKE $${paramIndex} OR a.name ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM financial.accounts a ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT a.*, + at.name as account_type_name, + at.code as account_type_code, + ap.name as parent_name, + cur.code as currency_code + FROM financial.accounts a + LEFT JOIN financial.account_types at ON a.account_type_id = at.id + LEFT JOIN financial.accounts ap ON a.parent_id = ap.id + LEFT JOIN core.currencies cur ON a.currency_id = cur.id + ${whereClause} + ORDER BY a.code + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const account = await queryOne( + `SELECT a.*, + at.name as account_type_name, + at.code as account_type_code, + ap.name as parent_name, + cur.code as currency_code + FROM financial.accounts a + LEFT JOIN financial.account_types at ON a.account_type_id = at.id + LEFT JOIN financial.accounts ap ON a.parent_id = ap.id + LEFT JOIN core.currencies cur ON a.currency_id = cur.id + WHERE a.id = $1 AND a.tenant_id = $2 AND a.deleted_at IS NULL`, + [id, tenantId] + ); + + if (!account) { + throw new NotFoundError('Cuenta no encontrada'); + } + + return account; + } + + async create(dto: CreateAccountDto, tenantId: string, userId: string): Promise { + // Validate unique code within company + const existing = await queryOne( + `SELECT id FROM financial.accounts WHERE company_id = $1 AND code = $2 AND deleted_at IS NULL`, + [dto.company_id, dto.code] + ); + if (existing) { + throw new ConflictError(`Ya existe una cuenta con código ${dto.code}`); + } + + // Validate account type exists + await this.findAccountTypeById(dto.account_type_id); + + // Validate parent account if specified + if (dto.parent_id) { + const parent = await queryOne( + `SELECT id FROM financial.accounts WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL`, + [dto.parent_id, dto.company_id] + ); + if (!parent) { + throw new NotFoundError('Cuenta padre no encontrada'); + } + } + + const account = await queryOne( + `INSERT INTO financial.accounts (tenant_id, company_id, code, name, account_type_id, parent_id, currency_id, is_reconcilable, notes, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [ + tenantId, + dto.company_id, + dto.code, + dto.name, + dto.account_type_id, + dto.parent_id, + dto.currency_id, + dto.is_reconcilable || false, + dto.notes, + userId, + ] + ); + + return account!; + } + + async update(id: string, dto: UpdateAccountDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + // Validate parent (prevent self-reference) + if (dto.parent_id) { + if (dto.parent_id === id) { + throw new ConflictError('Una cuenta no puede ser su propia cuenta padre'); + } + const parent = await queryOne( + `SELECT id FROM financial.accounts WHERE id = $1 AND company_id = $2 AND deleted_at IS NULL`, + [dto.parent_id, existing.company_id] + ); + if (!parent) { + throw new NotFoundError('Cuenta padre no encontrada'); + } + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.name !== undefined) { + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.parent_id !== undefined) { + updateFields.push(`parent_id = $${paramIndex++}`); + values.push(dto.parent_id); + } + if (dto.currency_id !== undefined) { + updateFields.push(`currency_id = $${paramIndex++}`); + values.push(dto.currency_id); + } + if (dto.is_reconcilable !== undefined) { + updateFields.push(`is_reconcilable = $${paramIndex++}`); + values.push(dto.is_reconcilable); + } + if (dto.is_deprecated !== undefined) { + updateFields.push(`is_deprecated = $${paramIndex++}`); + values.push(dto.is_deprecated); + } + if (dto.notes !== undefined) { + updateFields.push(`notes = $${paramIndex++}`); + values.push(dto.notes); + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + const account = await queryOne( + `UPDATE financial.accounts + SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} AND deleted_at IS NULL + RETURNING *`, + values + ); + + return account!; + } + + async delete(id: string, tenantId: string, userId: string): Promise { + await this.findById(id, tenantId); + + // Check if account has children + const children = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM financial.accounts WHERE parent_id = $1 AND deleted_at IS NULL`, + [id] + ); + if (parseInt(children?.count || '0', 10) > 0) { + throw new ConflictError('No se puede eliminar una cuenta que tiene subcuentas'); + } + + // Check if account has journal entry lines + const entries = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM financial.journal_entry_lines WHERE account_id = $1`, + [id] + ); + if (parseInt(entries?.count || '0', 10) > 0) { + throw new ConflictError('No se puede eliminar una cuenta que tiene movimientos contables'); + } + + // Soft delete + await query( + `UPDATE financial.accounts SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $1 WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + } + + async getBalance(accountId: string, tenantId: string): Promise<{ debit: number; credit: number; balance: number }> { + await this.findById(accountId, tenantId); + + const result = await queryOne<{ total_debit: string; total_credit: string }>( + `SELECT COALESCE(SUM(jel.debit), 0) as total_debit, + COALESCE(SUM(jel.credit), 0) as total_credit + FROM financial.journal_entry_lines jel + INNER JOIN financial.journal_entries je ON jel.entry_id = je.id + WHERE jel.account_id = $1 AND je.status = 'posted'`, + [accountId] + ); + + const debit = parseFloat(result?.total_debit || '0'); + const credit = parseFloat(result?.total_credit || '0'); + + return { + debit, + credit, + balance: debit - credit, + }; + } +} + +export const accountsService = new AccountsService(); diff --git a/src/modules/financial/accounts.service.ts b/src/modules/financial/accounts.service.ts new file mode 100644 index 0000000..8cbc8ec --- /dev/null +++ b/src/modules/financial/accounts.service.ts @@ -0,0 +1,468 @@ +import { Repository, IsNull } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { Account, AccountType } from './entities/index.js'; +import { NotFoundError, ValidationError, ForbiddenError } from '../../shared/types/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ===== Interfaces ===== + +export interface CreateAccountDto { + companyId: string; + code: string; + name: string; + accountTypeId: string; + parentId?: string; + currencyId?: string; + isReconcilable?: boolean; + notes?: string; +} + +export interface UpdateAccountDto { + name?: string; + parentId?: string | null; + currencyId?: string | null; + isReconcilable?: boolean; + isDeprecated?: boolean; + notes?: string | null; +} + +export interface AccountFilters { + companyId?: string; + accountTypeId?: string; + parentId?: string; + isDeprecated?: boolean; + search?: string; + page?: number; + limit?: number; +} + +export interface AccountWithRelations extends Account { + accountTypeName?: string; + accountTypeCode?: string; + parentName?: string; + currencyCode?: string; +} + +// ===== AccountsService Class ===== + +class AccountsService { + private accountRepository: Repository; + private accountTypeRepository: Repository; + + constructor() { + this.accountRepository = AppDataSource.getRepository(Account); + this.accountTypeRepository = AppDataSource.getRepository(AccountType); + } + + /** + * Get all account types (catalog) + */ + async findAllAccountTypes(): Promise { + return this.accountTypeRepository.find({ + order: { code: 'ASC' }, + }); + } + + /** + * Get account type by ID + */ + async findAccountTypeById(id: string): Promise { + const accountType = await this.accountTypeRepository.findOne({ + where: { id }, + }); + + if (!accountType) { + throw new NotFoundError('Tipo de cuenta no encontrado'); + } + + return accountType; + } + + /** + * Get all accounts with filters and pagination + */ + async findAll( + tenantId: string, + filters: AccountFilters = {} + ): Promise<{ data: AccountWithRelations[]; total: number }> { + try { + const { + companyId, + accountTypeId, + parentId, + isDeprecated, + search, + page = 1, + limit = 50 + } = filters; + const skip = (page - 1) * limit; + + const queryBuilder = this.accountRepository + .createQueryBuilder('account') + .leftJoin('account.accountType', 'accountType') + .addSelect(['accountType.name', 'accountType.code']) + .leftJoin('account.parent', 'parent') + .addSelect(['parent.name']) + .where('account.tenantId = :tenantId', { tenantId }) + .andWhere('account.deletedAt IS NULL'); + + // Apply filters + if (companyId) { + queryBuilder.andWhere('account.companyId = :companyId', { companyId }); + } + + if (accountTypeId) { + queryBuilder.andWhere('account.accountTypeId = :accountTypeId', { accountTypeId }); + } + + if (parentId !== undefined) { + if (parentId === null || parentId === 'null') { + queryBuilder.andWhere('account.parentId IS NULL'); + } else { + queryBuilder.andWhere('account.parentId = :parentId', { parentId }); + } + } + + if (isDeprecated !== undefined) { + queryBuilder.andWhere('account.isDeprecated = :isDeprecated', { isDeprecated }); + } + + if (search) { + queryBuilder.andWhere( + '(account.code ILIKE :search OR account.name ILIKE :search)', + { search: `%${search}%` } + ); + } + + // Get total count + const total = await queryBuilder.getCount(); + + // Get paginated results + const accounts = await queryBuilder + .orderBy('account.code', 'ASC') + .skip(skip) + .take(limit) + .getMany(); + + // Map to include relation names + const data: AccountWithRelations[] = accounts.map(account => ({ + ...account, + accountTypeName: account.accountType?.name, + accountTypeCode: account.accountType?.code, + parentName: account.parent?.name, + })); + + logger.debug('Accounts retrieved', { tenantId, count: data.length, total }); + + return { data, total }; + } catch (error) { + logger.error('Error retrieving accounts', { + error: (error as Error).message, + tenantId, + }); + throw error; + } + } + + /** + * Get account by ID + */ + async findById(id: string, tenantId: string): Promise { + try { + const account = await this.accountRepository + .createQueryBuilder('account') + .leftJoin('account.accountType', 'accountType') + .addSelect(['accountType.name', 'accountType.code']) + .leftJoin('account.parent', 'parent') + .addSelect(['parent.name']) + .where('account.id = :id', { id }) + .andWhere('account.tenantId = :tenantId', { tenantId }) + .andWhere('account.deletedAt IS NULL') + .getOne(); + + if (!account) { + throw new NotFoundError('Cuenta no encontrada'); + } + + return { + ...account, + accountTypeName: account.accountType?.name, + accountTypeCode: account.accountType?.code, + parentName: account.parent?.name, + }; + } catch (error) { + logger.error('Error finding account', { + error: (error as Error).message, + id, + tenantId, + }); + throw error; + } + } + + /** + * Create a new account + */ + async create( + dto: CreateAccountDto, + tenantId: string, + userId: string + ): Promise { + try { + // Validate unique code within company + const existing = await this.accountRepository.findOne({ + where: { + companyId: dto.companyId, + code: dto.code, + deletedAt: IsNull(), + }, + }); + + if (existing) { + throw new ValidationError(`Ya existe una cuenta con código ${dto.code}`); + } + + // Validate account type exists + await this.findAccountTypeById(dto.accountTypeId); + + // Validate parent account if specified + if (dto.parentId) { + const parent = await this.accountRepository.findOne({ + where: { + id: dto.parentId, + companyId: dto.companyId, + deletedAt: IsNull(), + }, + }); + + if (!parent) { + throw new NotFoundError('Cuenta padre no encontrada'); + } + } + + // Create account + const account = this.accountRepository.create({ + tenantId, + companyId: dto.companyId, + code: dto.code, + name: dto.name, + accountTypeId: dto.accountTypeId, + parentId: dto.parentId || null, + currencyId: dto.currencyId || null, + isReconcilable: dto.isReconcilable || false, + notes: dto.notes || null, + createdBy: userId, + }); + + await this.accountRepository.save(account); + + logger.info('Account created', { + accountId: account.id, + tenantId, + code: account.code, + createdBy: userId, + }); + + return account; + } catch (error) { + logger.error('Error creating account', { + error: (error as Error).message, + tenantId, + dto, + }); + throw error; + } + } + + /** + * Update an account + */ + async update( + id: string, + dto: UpdateAccountDto, + tenantId: string, + userId: string + ): Promise { + try { + const existing = await this.findById(id, tenantId); + + // Validate parent (prevent self-reference and cycles) + if (dto.parentId !== undefined && dto.parentId) { + if (dto.parentId === id) { + throw new ValidationError('Una cuenta no puede ser su propia cuenta padre'); + } + + const parent = await this.accountRepository.findOne({ + where: { + id: dto.parentId, + companyId: existing.companyId, + deletedAt: IsNull(), + }, + }); + + if (!parent) { + throw new NotFoundError('Cuenta padre no encontrada'); + } + + // Check for circular reference + if (await this.wouldCreateCycle(id, dto.parentId, tenantId)) { + throw new ValidationError('La asignación crearía una referencia circular'); + } + } + + // Update allowed fields + if (dto.name !== undefined) existing.name = dto.name; + if (dto.parentId !== undefined) existing.parentId = dto.parentId; + if (dto.currencyId !== undefined) existing.currencyId = dto.currencyId; + if (dto.isReconcilable !== undefined) existing.isReconcilable = dto.isReconcilable; + if (dto.isDeprecated !== undefined) existing.isDeprecated = dto.isDeprecated; + if (dto.notes !== undefined) existing.notes = dto.notes; + + existing.updatedBy = userId; + existing.updatedAt = new Date(); + + await this.accountRepository.save(existing); + + logger.info('Account updated', { + accountId: id, + tenantId, + updatedBy: userId, + }); + + return await this.findById(id, tenantId); + } catch (error) { + logger.error('Error updating account', { + error: (error as Error).message, + id, + tenantId, + }); + throw error; + } + } + + /** + * Soft delete an account + */ + async delete(id: string, tenantId: string, userId: string): Promise { + try { + await this.findById(id, tenantId); + + // Check if account has children + const childrenCount = await this.accountRepository.count({ + where: { + parentId: id, + deletedAt: IsNull(), + }, + }); + + if (childrenCount > 0) { + throw new ForbiddenError('No se puede eliminar una cuenta que tiene subcuentas'); + } + + // Check if account has journal entry lines (use raw query for this check) + const entryLinesCheck = await this.accountRepository.query( + `SELECT COUNT(*) as count FROM financial.journal_entry_lines WHERE account_id = $1`, + [id] + ); + + if (parseInt(entryLinesCheck[0]?.count || '0', 10) > 0) { + throw new ForbiddenError('No se puede eliminar una cuenta que tiene movimientos contables'); + } + + // Soft delete + await this.accountRepository.update( + { id, tenantId }, + { + deletedAt: new Date(), + deletedBy: userId, + } + ); + + logger.info('Account deleted', { + accountId: id, + tenantId, + deletedBy: userId, + }); + } catch (error) { + logger.error('Error deleting account', { + error: (error as Error).message, + id, + tenantId, + }); + throw error; + } + } + + /** + * Get account balance + */ + async getBalance( + accountId: string, + tenantId: string + ): Promise<{ debit: number; credit: number; balance: number }> { + try { + await this.findById(accountId, tenantId); + + const result = await this.accountRepository.query( + `SELECT COALESCE(SUM(jel.debit), 0) as total_debit, + COALESCE(SUM(jel.credit), 0) as total_credit + FROM financial.journal_entry_lines jel + INNER JOIN financial.journal_entries je ON jel.entry_id = je.id + WHERE jel.account_id = $1 AND je.status = 'posted'`, + [accountId] + ); + + const debit = parseFloat(result[0]?.total_debit || '0'); + const credit = parseFloat(result[0]?.total_credit || '0'); + + return { + debit, + credit, + balance: debit - credit, + }; + } catch (error) { + logger.error('Error getting account balance', { + error: (error as Error).message, + accountId, + tenantId, + }); + throw error; + } + } + + /** + * Check if assigning a parent would create a circular reference + */ + private async wouldCreateCycle( + accountId: string, + newParentId: string, + tenantId: string + ): Promise { + let currentId: string | null = newParentId; + const visited = new Set(); + + while (currentId) { + if (visited.has(currentId)) { + return true; // Found a cycle + } + if (currentId === accountId) { + return true; // Would create a cycle + } + + visited.add(currentId); + + const parent = await this.accountRepository.findOne({ + where: { id: currentId, tenantId, deletedAt: IsNull() }, + select: ['parentId'], + }); + + currentId = parent?.parentId || null; + } + + return false; + } +} + +// ===== Export Singleton Instance ===== + +export const accountsService = new AccountsService(); diff --git a/src/modules/financial/entities/account-type.entity.ts b/src/modules/financial/entities/account-type.entity.ts new file mode 100644 index 0000000..a4fe1d0 --- /dev/null +++ b/src/modules/financial/entities/account-type.entity.ts @@ -0,0 +1,38 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export enum AccountTypeEnum { + ASSET = 'asset', + LIABILITY = 'liability', + EQUITY = 'equity', + INCOME = 'income', + EXPENSE = 'expense', +} + +@Entity({ schema: 'financial', name: 'account_types' }) +@Index('idx_account_types_code', ['code'], { unique: true }) +export class AccountType { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 20, nullable: false, unique: true }) + code: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ + type: 'enum', + enum: AccountTypeEnum, + nullable: false, + name: 'account_type', + }) + accountType: AccountTypeEnum; + + @Column({ type: 'text', nullable: true }) + description: string | null; +} diff --git a/src/modules/financial/entities/account.entity.ts b/src/modules/financial/entities/account.entity.ts new file mode 100644 index 0000000..5db7d67 --- /dev/null +++ b/src/modules/financial/entities/account.entity.ts @@ -0,0 +1,93 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; +import { AccountType } from './account-type.entity.js'; +import { Company } from '../../auth/entities/company.entity.js'; + +@Entity({ schema: 'financial', name: 'accounts' }) +@Index('idx_accounts_tenant_id', ['tenantId']) +@Index('idx_accounts_company_id', ['companyId']) +@Index('idx_accounts_code', ['companyId', 'code'], { unique: true, where: 'deleted_at IS NULL' }) +@Index('idx_accounts_parent_id', ['parentId']) +@Index('idx_accounts_account_type_id', ['accountTypeId']) +export class Account { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'company_id' }) + companyId: string; + + @Column({ type: 'varchar', length: 50, nullable: false }) + code: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'uuid', nullable: false, name: 'account_type_id' }) + accountTypeId: string; + + @Column({ type: 'uuid', nullable: true, name: 'parent_id' }) + parentId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'currency_id' }) + currencyId: string | null; + + @Column({ type: 'boolean', default: false, nullable: false, name: 'is_reconcilable' }) + isReconcilable: boolean; + + @Column({ type: 'boolean', default: false, nullable: false, name: 'is_deprecated' }) + isDeprecated: boolean; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + // Relations + @ManyToOne(() => Company) + @JoinColumn({ name: 'company_id' }) + company: Company; + + @ManyToOne(() => AccountType) + @JoinColumn({ name: 'account_type_id' }) + accountType: AccountType; + + @ManyToOne(() => Account, (account) => account.children) + @JoinColumn({ name: 'parent_id' }) + parent: Account | null; + + @OneToMany(() => Account, (account) => account.parent) + children: Account[]; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + deletedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) + deletedBy: string | null; +} diff --git a/src/modules/financial/entities/fiscal-period.entity.ts b/src/modules/financial/entities/fiscal-period.entity.ts new file mode 100644 index 0000000..b3f92a3 --- /dev/null +++ b/src/modules/financial/entities/fiscal-period.entity.ts @@ -0,0 +1,64 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { FiscalYear, FiscalPeriodStatus } from './fiscal-year.entity.js'; + +@Entity({ schema: 'financial', name: 'fiscal_periods' }) +@Index('idx_fiscal_periods_tenant_id', ['tenantId']) +@Index('idx_fiscal_periods_fiscal_year_id', ['fiscalYearId']) +@Index('idx_fiscal_periods_dates', ['dateFrom', 'dateTo']) +@Index('idx_fiscal_periods_status', ['status']) +export class FiscalPeriod { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'fiscal_year_id' }) + fiscalYearId: string; + + @Column({ type: 'varchar', length: 20, nullable: false }) + code: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ type: 'date', nullable: false, name: 'date_from' }) + dateFrom: Date; + + @Column({ type: 'date', nullable: false, name: 'date_to' }) + dateTo: Date; + + @Column({ + type: 'enum', + enum: FiscalPeriodStatus, + default: FiscalPeriodStatus.OPEN, + nullable: false, + }) + status: FiscalPeriodStatus; + + @Column({ type: 'timestamp', nullable: true, name: 'closed_at' }) + closedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'closed_by' }) + closedBy: string | null; + + // Relations + @ManyToOne(() => FiscalYear, (year) => year.periods) + @JoinColumn({ name: 'fiscal_year_id' }) + fiscalYear: FiscalYear; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; +} diff --git a/src/modules/financial/entities/fiscal-year.entity.ts b/src/modules/financial/entities/fiscal-year.entity.ts new file mode 100644 index 0000000..7a7866e --- /dev/null +++ b/src/modules/financial/entities/fiscal-year.entity.ts @@ -0,0 +1,67 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; +import { Company } from '../../auth/entities/company.entity.js'; +import { FiscalPeriod } from './fiscal-period.entity.js'; + +export enum FiscalPeriodStatus { + OPEN = 'open', + CLOSED = 'closed', +} + +@Entity({ schema: 'financial', name: 'fiscal_years' }) +@Index('idx_fiscal_years_tenant_id', ['tenantId']) +@Index('idx_fiscal_years_company_id', ['companyId']) +@Index('idx_fiscal_years_dates', ['dateFrom', 'dateTo']) +export class FiscalYear { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'company_id' }) + companyId: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 20, nullable: false }) + code: string; + + @Column({ type: 'date', nullable: false, name: 'date_from' }) + dateFrom: Date; + + @Column({ type: 'date', nullable: false, name: 'date_to' }) + dateTo: Date; + + @Column({ + type: 'enum', + enum: FiscalPeriodStatus, + default: FiscalPeriodStatus.OPEN, + nullable: false, + }) + status: FiscalPeriodStatus; + + // Relations + @ManyToOne(() => Company) + @JoinColumn({ name: 'company_id' }) + company: Company; + + @OneToMany(() => FiscalPeriod, (period) => period.fiscalYear) + periods: FiscalPeriod[]; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; +} diff --git a/src/modules/financial/entities/index.ts b/src/modules/financial/entities/index.ts new file mode 100644 index 0000000..a142e49 --- /dev/null +++ b/src/modules/financial/entities/index.ts @@ -0,0 +1,22 @@ +// Account entities +export { AccountType, AccountTypeEnum } from './account-type.entity.js'; +export { Account } from './account.entity.js'; + +// Journal entities +export { Journal, JournalType } from './journal.entity.js'; +export { JournalEntry, EntryStatus } from './journal-entry.entity.js'; +export { JournalEntryLine } from './journal-entry-line.entity.js'; + +// Invoice entities +export { Invoice, InvoiceType, InvoiceStatus } from './invoice.entity.js'; +export { InvoiceLine } from './invoice-line.entity.js'; + +// Payment entities +export { Payment, PaymentType, PaymentMethod, PaymentStatus } from './payment.entity.js'; + +// Tax entities +export { Tax, TaxType } from './tax.entity.js'; + +// Fiscal period entities +export { FiscalYear, FiscalPeriodStatus } from './fiscal-year.entity.js'; +export { FiscalPeriod } from './fiscal-period.entity.js'; diff --git a/src/modules/financial/entities/invoice-line.entity.ts b/src/modules/financial/entities/invoice-line.entity.ts new file mode 100644 index 0000000..33f875f --- /dev/null +++ b/src/modules/financial/entities/invoice-line.entity.ts @@ -0,0 +1,79 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Invoice } from './invoice.entity.js'; +import { Account } from './account.entity.js'; + +@Entity({ schema: 'financial', name: 'invoice_lines' }) +@Index('idx_invoice_lines_invoice_id', ['invoiceId']) +@Index('idx_invoice_lines_tenant_id', ['tenantId']) +@Index('idx_invoice_lines_product_id', ['productId']) +export class InvoiceLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'invoice_id' }) + invoiceId: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: true, name: 'product_id' }) + productId: string | null; + + @Column({ type: 'text', nullable: false }) + description: string; + + @Column({ type: 'decimal', precision: 15, scale: 4, nullable: false }) + quantity: number; + + @Column({ type: 'uuid', nullable: true, name: 'uom_id' }) + uomId: string | null; + + @Column({ type: 'decimal', precision: 15, scale: 2, nullable: false, name: 'price_unit' }) + priceUnit: number; + + @Column({ type: 'uuid', array: true, default: '{}', name: 'tax_ids' }) + taxIds: string[]; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false, name: 'amount_untaxed' }) + amountUntaxed: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false, name: 'amount_tax' }) + amountTax: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false, name: 'amount_total' }) + amountTotal: number; + + @Column({ type: 'uuid', nullable: true, name: 'account_id' }) + accountId: string | null; + + // Relations + @ManyToOne(() => Invoice, (invoice) => invoice.lines, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'invoice_id' }) + invoice: Invoice; + + @ManyToOne(() => Account) + @JoinColumn({ name: 'account_id' }) + account: Account | null; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; +} diff --git a/src/modules/financial/entities/invoice.entity.ts b/src/modules/financial/entities/invoice.entity.ts new file mode 100644 index 0000000..3f98a19 --- /dev/null +++ b/src/modules/financial/entities/invoice.entity.ts @@ -0,0 +1,152 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; +import { Company } from '../../auth/entities/company.entity.js'; +import { Journal } from './journal.entity.js'; +import { JournalEntry } from './journal-entry.entity.js'; +import { InvoiceLine } from './invoice-line.entity.js'; + +export enum InvoiceType { + CUSTOMER = 'customer', + SUPPLIER = 'supplier', +} + +export enum InvoiceStatus { + DRAFT = 'draft', + OPEN = 'open', + PAID = 'paid', + CANCELLED = 'cancelled', +} + +@Entity({ schema: 'financial', name: 'invoices' }) +@Index('idx_invoices_tenant_id', ['tenantId']) +@Index('idx_invoices_company_id', ['companyId']) +@Index('idx_invoices_partner_id', ['partnerId']) +@Index('idx_invoices_number', ['number']) +@Index('idx_invoices_date', ['invoiceDate']) +@Index('idx_invoices_status', ['status']) +@Index('idx_invoices_type', ['invoiceType']) +export class Invoice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'company_id' }) + companyId: string; + + @Column({ type: 'uuid', nullable: false, name: 'partner_id' }) + partnerId: string; + + @Column({ + type: 'enum', + enum: InvoiceType, + nullable: false, + name: 'invoice_type', + }) + invoiceType: InvoiceType; + + @Column({ type: 'varchar', length: 100, nullable: true }) + number: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + ref: string | null; + + @Column({ type: 'date', nullable: false, name: 'invoice_date' }) + invoiceDate: Date; + + @Column({ type: 'date', nullable: true, name: 'due_date' }) + dueDate: Date | null; + + @Column({ type: 'uuid', nullable: false, name: 'currency_id' }) + currencyId: string; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false, name: 'amount_untaxed' }) + amountUntaxed: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false, name: 'amount_tax' }) + amountTax: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false, name: 'amount_total' }) + amountTotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false, name: 'amount_paid' }) + amountPaid: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false, name: 'amount_residual' }) + amountResidual: number; + + @Column({ + type: 'enum', + enum: InvoiceStatus, + default: InvoiceStatus.DRAFT, + nullable: false, + }) + status: InvoiceStatus; + + @Column({ type: 'uuid', nullable: true, name: 'payment_term_id' }) + paymentTermId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'journal_id' }) + journalId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'journal_entry_id' }) + journalEntryId: string | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + // Relations + @ManyToOne(() => Company) + @JoinColumn({ name: 'company_id' }) + company: Company; + + @ManyToOne(() => Journal) + @JoinColumn({ name: 'journal_id' }) + journal: Journal | null; + + @ManyToOne(() => JournalEntry) + @JoinColumn({ name: 'journal_entry_id' }) + journalEntry: JournalEntry | null; + + @OneToMany(() => InvoiceLine, (line) => line.invoice, { cascade: true }) + lines: InvoiceLine[]; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'validated_at' }) + validatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'validated_by' }) + validatedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'cancelled_at' }) + cancelledAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'cancelled_by' }) + cancelledBy: string | null; +} diff --git a/src/modules/financial/entities/journal-entry-line.entity.ts b/src/modules/financial/entities/journal-entry-line.entity.ts new file mode 100644 index 0000000..7fd8fd1 --- /dev/null +++ b/src/modules/financial/entities/journal-entry-line.entity.ts @@ -0,0 +1,59 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { JournalEntry } from './journal-entry.entity.js'; +import { Account } from './account.entity.js'; + +@Entity({ schema: 'financial', name: 'journal_entry_lines' }) +@Index('idx_journal_entry_lines_entry_id', ['entryId']) +@Index('idx_journal_entry_lines_account_id', ['accountId']) +@Index('idx_journal_entry_lines_tenant_id', ['tenantId']) +export class JournalEntryLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'entry_id' }) + entryId: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'account_id' }) + accountId: string; + + @Column({ type: 'uuid', nullable: true, name: 'partner_id' }) + partnerId: string | null; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false }) + debit: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0, nullable: false }) + credit: number; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + ref: string | null; + + // Relations + @ManyToOne(() => JournalEntry, (entry) => entry.lines, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'entry_id' }) + entry: JournalEntry; + + @ManyToOne(() => Account) + @JoinColumn({ name: 'account_id' }) + account: Account; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} diff --git a/src/modules/financial/entities/journal-entry.entity.ts b/src/modules/financial/entities/journal-entry.entity.ts new file mode 100644 index 0000000..4513a1d --- /dev/null +++ b/src/modules/financial/entities/journal-entry.entity.ts @@ -0,0 +1,104 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; +import { Company } from '../../auth/entities/company.entity.js'; +import { Journal } from './journal.entity.js'; +import { JournalEntryLine } from './journal-entry-line.entity.js'; + +export enum EntryStatus { + DRAFT = 'draft', + POSTED = 'posted', + CANCELLED = 'cancelled', +} + +@Entity({ schema: 'financial', name: 'journal_entries' }) +@Index('idx_journal_entries_tenant_id', ['tenantId']) +@Index('idx_journal_entries_company_id', ['companyId']) +@Index('idx_journal_entries_journal_id', ['journalId']) +@Index('idx_journal_entries_date', ['date']) +@Index('idx_journal_entries_status', ['status']) +export class JournalEntry { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'company_id' }) + companyId: string; + + @Column({ type: 'uuid', nullable: false, name: 'journal_id' }) + journalId: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + ref: string | null; + + @Column({ type: 'date', nullable: false }) + date: Date; + + @Column({ + type: 'enum', + enum: EntryStatus, + default: EntryStatus.DRAFT, + nullable: false, + }) + status: EntryStatus; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'fiscal_period_id' }) + fiscalPeriodId: string | null; + + // Relations + @ManyToOne(() => Company) + @JoinColumn({ name: 'company_id' }) + company: Company; + + @ManyToOne(() => Journal) + @JoinColumn({ name: 'journal_id' }) + journal: Journal; + + @OneToMany(() => JournalEntryLine, (line) => line.entry, { cascade: true }) + lines: JournalEntryLine[]; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'posted_at' }) + postedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'posted_by' }) + postedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'cancelled_at' }) + cancelledAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'cancelled_by' }) + cancelledBy: string | null; +} diff --git a/src/modules/financial/entities/journal.entity.ts b/src/modules/financial/entities/journal.entity.ts new file mode 100644 index 0000000..6a09088 --- /dev/null +++ b/src/modules/financial/entities/journal.entity.ts @@ -0,0 +1,94 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Company } from '../../auth/entities/company.entity.js'; +import { Account } from './account.entity.js'; + +export enum JournalType { + SALE = 'sale', + PURCHASE = 'purchase', + CASH = 'cash', + BANK = 'bank', + GENERAL = 'general', +} + +@Entity({ schema: 'financial', name: 'journals' }) +@Index('idx_journals_tenant_id', ['tenantId']) +@Index('idx_journals_company_id', ['companyId']) +@Index('idx_journals_code', ['companyId', 'code'], { unique: true, where: 'deleted_at IS NULL' }) +@Index('idx_journals_type', ['journalType']) +export class Journal { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'company_id' }) + companyId: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 20, nullable: false }) + code: string; + + @Column({ + type: 'enum', + enum: JournalType, + nullable: false, + name: 'journal_type', + }) + journalType: JournalType; + + @Column({ type: 'uuid', nullable: true, name: 'default_account_id' }) + defaultAccountId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'sequence_id' }) + sequenceId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'currency_id' }) + currencyId: string | null; + + @Column({ type: 'boolean', default: true, nullable: false }) + active: boolean; + + // Relations + @ManyToOne(() => Company) + @JoinColumn({ name: 'company_id' }) + company: Company; + + @ManyToOne(() => Account) + @JoinColumn({ name: 'default_account_id' }) + defaultAccount: Account | null; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + deletedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) + deletedBy: string | null; +} diff --git a/src/modules/financial/entities/payment.entity.ts b/src/modules/financial/entities/payment.entity.ts new file mode 100644 index 0000000..e1ca757 --- /dev/null +++ b/src/modules/financial/entities/payment.entity.ts @@ -0,0 +1,135 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Company } from '../../auth/entities/company.entity.js'; +import { Journal } from './journal.entity.js'; +import { JournalEntry } from './journal-entry.entity.js'; + +export enum PaymentType { + INBOUND = 'inbound', + OUTBOUND = 'outbound', +} + +export enum PaymentMethod { + CASH = 'cash', + BANK_TRANSFER = 'bank_transfer', + CHECK = 'check', + CARD = 'card', + OTHER = 'other', +} + +export enum PaymentStatus { + DRAFT = 'draft', + POSTED = 'posted', + RECONCILED = 'reconciled', + CANCELLED = 'cancelled', +} + +@Entity({ schema: 'financial', name: 'payments' }) +@Index('idx_payments_tenant_id', ['tenantId']) +@Index('idx_payments_company_id', ['companyId']) +@Index('idx_payments_partner_id', ['partnerId']) +@Index('idx_payments_date', ['paymentDate']) +@Index('idx_payments_status', ['status']) +@Index('idx_payments_type', ['paymentType']) +export class Payment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'company_id' }) + companyId: string; + + @Column({ type: 'uuid', nullable: false, name: 'partner_id' }) + partnerId: string; + + @Column({ + type: 'enum', + enum: PaymentType, + nullable: false, + name: 'payment_type', + }) + paymentType: PaymentType; + + @Column({ + type: 'enum', + enum: PaymentMethod, + nullable: false, + name: 'payment_method', + }) + paymentMethod: PaymentMethod; + + @Column({ type: 'decimal', precision: 15, scale: 2, nullable: false }) + amount: number; + + @Column({ type: 'uuid', nullable: false, name: 'currency_id' }) + currencyId: string; + + @Column({ type: 'date', nullable: false, name: 'payment_date' }) + paymentDate: Date; + + @Column({ type: 'varchar', length: 255, nullable: true }) + ref: string | null; + + @Column({ + type: 'enum', + enum: PaymentStatus, + default: PaymentStatus.DRAFT, + nullable: false, + }) + status: PaymentStatus; + + @Column({ type: 'uuid', nullable: false, name: 'journal_id' }) + journalId: string; + + @Column({ type: 'uuid', nullable: true, name: 'journal_entry_id' }) + journalEntryId: string | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + // Relations + @ManyToOne(() => Company) + @JoinColumn({ name: 'company_id' }) + company: Company; + + @ManyToOne(() => Journal) + @JoinColumn({ name: 'journal_id' }) + journal: Journal; + + @ManyToOne(() => JournalEntry) + @JoinColumn({ name: 'journal_entry_id' }) + journalEntry: JournalEntry | null; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'posted_at' }) + postedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'posted_by' }) + postedBy: string | null; +} diff --git a/src/modules/financial/entities/tax.entity.ts b/src/modules/financial/entities/tax.entity.ts new file mode 100644 index 0000000..ca490a5 --- /dev/null +++ b/src/modules/financial/entities/tax.entity.ts @@ -0,0 +1,78 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Company } from '../../auth/entities/company.entity.js'; + +export enum TaxType { + SALES = 'sales', + PURCHASE = 'purchase', + ALL = 'all', +} + +@Entity({ schema: 'financial', name: 'taxes' }) +@Index('idx_taxes_tenant_id', ['tenantId']) +@Index('idx_taxes_company_id', ['companyId']) +@Index('idx_taxes_code', ['tenantId', 'code'], { unique: true }) +@Index('idx_taxes_type', ['taxType']) +export class Tax { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'company_id' }) + companyId: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 20, nullable: false }) + code: string; + + @Column({ + type: 'enum', + enum: TaxType, + nullable: false, + name: 'tax_type', + }) + taxType: TaxType; + + @Column({ type: 'decimal', precision: 5, scale: 2, nullable: false }) + amount: number; + + @Column({ type: 'boolean', default: false, nullable: false, name: 'included_in_price' }) + includedInPrice: boolean; + + @Column({ type: 'boolean', default: true, nullable: false }) + active: boolean; + + // Relations + @ManyToOne(() => Company) + @JoinColumn({ name: 'company_id' }) + company: Company; + + // Audit fields + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; +} diff --git a/src/modules/financial/financial.controller.ts b/src/modules/financial/financial.controller.ts new file mode 100644 index 0000000..b2d7822 --- /dev/null +++ b/src/modules/financial/financial.controller.ts @@ -0,0 +1,753 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { accountsService, CreateAccountDto, UpdateAccountDto, AccountFilters } from './accounts.service.js'; +import { journalsService, CreateJournalDto, UpdateJournalDto, JournalFilters } from './journals.service.js'; +import { journalEntriesService, CreateJournalEntryDto, UpdateJournalEntryDto, JournalEntryFilters } from './journal-entries.service.js'; +import { invoicesService, CreateInvoiceDto, UpdateInvoiceDto, CreateInvoiceLineDto, UpdateInvoiceLineDto, InvoiceFilters } from './invoices.service.js'; +import { paymentsService, CreatePaymentDto, UpdatePaymentDto, ReconcileDto, PaymentFilters } from './payments.service.js'; +import { taxesService, CreateTaxDto, UpdateTaxDto, TaxFilters } from './taxes.service.js'; +import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js'; +import { ValidationError } from '../../shared/errors/index.js'; + +// Schemas +const createAccountSchema = z.object({ + company_id: z.string().uuid(), + code: z.string().min(1).max(50), + name: z.string().min(1).max(255), + account_type_id: z.string().uuid(), + parent_id: z.string().uuid().optional(), + currency_id: z.string().uuid().optional(), + is_reconcilable: z.boolean().default(false), + notes: z.string().optional(), +}); + +const updateAccountSchema = z.object({ + name: z.string().min(1).max(255).optional(), + parent_id: z.string().uuid().optional().nullable(), + currency_id: z.string().uuid().optional().nullable(), + is_reconcilable: z.boolean().optional(), + is_deprecated: z.boolean().optional(), + notes: z.string().optional().nullable(), +}); + +const accountQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + account_type_id: z.string().uuid().optional(), + parent_id: z.string().optional(), + is_deprecated: z.coerce.boolean().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(50), +}); + +const createJournalSchema = z.object({ + company_id: z.string().uuid(), + name: z.string().min(1).max(255), + code: z.string().min(1).max(20), + journal_type: z.enum(['sale', 'purchase', 'cash', 'bank', 'general']), + default_account_id: z.string().uuid().optional(), + sequence_id: z.string().uuid().optional(), + currency_id: z.string().uuid().optional(), +}); + +const updateJournalSchema = z.object({ + name: z.string().min(1).max(255).optional(), + default_account_id: z.string().uuid().optional().nullable(), + sequence_id: z.string().uuid().optional().nullable(), + currency_id: z.string().uuid().optional().nullable(), + active: z.boolean().optional(), +}); + +const journalQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + journal_type: z.enum(['sale', 'purchase', 'cash', 'bank', 'general']).optional(), + active: z.coerce.boolean().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(50), +}); + +const journalEntryLineSchema = z.object({ + account_id: z.string().uuid(), + partner_id: z.string().uuid().optional(), + debit: z.number().min(0).default(0), + credit: z.number().min(0).default(0), + description: z.string().optional(), + ref: z.string().optional(), +}); + +const createJournalEntrySchema = z.object({ + company_id: z.string().uuid(), + journal_id: z.string().uuid(), + name: z.string().min(1).max(100), + ref: z.string().max(255).optional(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + notes: z.string().optional(), + lines: z.array(journalEntryLineSchema).min(2), +}); + +const updateJournalEntrySchema = z.object({ + ref: z.string().max(255).optional().nullable(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + notes: z.string().optional().nullable(), + lines: z.array(journalEntryLineSchema).min(2).optional(), +}); + +const journalEntryQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + journal_id: z.string().uuid().optional(), + status: z.enum(['draft', 'posted', 'cancelled']).optional(), + date_from: z.string().optional(), + date_to: z.string().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +// ========== INVOICE SCHEMAS ========== +const createInvoiceSchema = z.object({ + company_id: z.string().uuid(), + partner_id: z.string().uuid(), + invoice_type: z.enum(['customer', 'supplier']), + currency_id: z.string().uuid(), + invoice_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + payment_term_id: z.string().uuid().optional(), + journal_id: z.string().uuid().optional(), + ref: z.string().optional(), + notes: z.string().optional(), +}); + +const updateInvoiceSchema = z.object({ + partner_id: z.string().uuid().optional(), + currency_id: z.string().uuid().optional(), + invoice_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), + payment_term_id: z.string().uuid().optional().nullable(), + journal_id: z.string().uuid().optional().nullable(), + ref: z.string().optional().nullable(), + notes: z.string().optional().nullable(), +}); + +const invoiceQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + partner_id: z.string().uuid().optional(), + invoice_type: z.enum(['customer', 'supplier']).optional(), + status: z.enum(['draft', 'open', 'paid', 'cancelled']).optional(), + date_from: z.string().optional(), + date_to: z.string().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +const createInvoiceLineSchema = z.object({ + product_id: z.string().uuid().optional(), + description: z.string().min(1), + quantity: z.number().positive(), + uom_id: z.string().uuid().optional(), + price_unit: z.number().min(0), + tax_ids: z.array(z.string().uuid()).optional(), + account_id: z.string().uuid().optional(), +}); + +const updateInvoiceLineSchema = z.object({ + product_id: z.string().uuid().optional().nullable(), + description: z.string().min(1).optional(), + quantity: z.number().positive().optional(), + uom_id: z.string().uuid().optional().nullable(), + price_unit: z.number().min(0).optional(), + tax_ids: z.array(z.string().uuid()).optional(), + account_id: z.string().uuid().optional().nullable(), +}); + +// ========== PAYMENT SCHEMAS ========== +const createPaymentSchema = z.object({ + company_id: z.string().uuid(), + partner_id: z.string().uuid(), + payment_type: z.enum(['inbound', 'outbound']), + payment_method: z.enum(['cash', 'bank_transfer', 'check', 'card', 'other']), + amount: z.number().positive(), + currency_id: z.string().uuid(), + payment_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + ref: z.string().optional(), + journal_id: z.string().uuid(), + notes: z.string().optional(), +}); + +const updatePaymentSchema = z.object({ + partner_id: z.string().uuid().optional(), + payment_method: z.enum(['cash', 'bank_transfer', 'check', 'card', 'other']).optional(), + amount: z.number().positive().optional(), + currency_id: z.string().uuid().optional(), + payment_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + ref: z.string().optional().nullable(), + journal_id: z.string().uuid().optional(), + notes: z.string().optional().nullable(), +}); + +const reconcilePaymentSchema = z.object({ + invoices: z.array(z.object({ + invoice_id: z.string().uuid(), + amount: z.number().positive(), + })).min(1), +}); + +const paymentQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + partner_id: z.string().uuid().optional(), + payment_type: z.enum(['inbound', 'outbound']).optional(), + payment_method: z.enum(['cash', 'bank_transfer', 'check', 'card', 'other']).optional(), + status: z.enum(['draft', 'posted', 'reconciled', 'cancelled']).optional(), + date_from: z.string().optional(), + date_to: z.string().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +// ========== TAX SCHEMAS ========== +const createTaxSchema = z.object({ + company_id: z.string().uuid(), + name: z.string().min(1).max(100), + code: z.string().min(1).max(20), + tax_type: z.enum(['sales', 'purchase', 'all']), + amount: z.number().min(0).max(100), + included_in_price: z.boolean().default(false), +}); + +const updateTaxSchema = z.object({ + name: z.string().min(1).max(100).optional(), + code: z.string().min(1).max(20).optional(), + tax_type: z.enum(['sales', 'purchase', 'all']).optional(), + amount: z.number().min(0).max(100).optional(), + included_in_price: z.boolean().optional(), + active: z.boolean().optional(), +}); + +const taxQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + tax_type: z.enum(['sales', 'purchase', 'all']).optional(), + active: z.coerce.boolean().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +class FinancialController { + // ========== ACCOUNT TYPES ========== + async getAccountTypes(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const accountTypes = await accountsService.findAllAccountTypes(); + res.json({ success: true, data: accountTypes }); + } catch (error) { + next(error); + } + } + + // ========== ACCOUNTS ========== + async getAccounts(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = accountQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + const filters: AccountFilters = queryResult.data; + const result = await accountsService.findAll(req.tenantId!, filters); + res.json({ + success: true, + data: result.data, + meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 50)) }, + }); + } catch (error) { + next(error); + } + } + + async getAccount(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const account = await accountsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: account }); + } catch (error) { + next(error); + } + } + + async createAccount(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createAccountSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de cuenta inválidos', parseResult.error.errors); + } + const dto: CreateAccountDto = parseResult.data; + const account = await accountsService.create(dto, req.tenantId!, req.user!.userId); + res.status(201).json({ success: true, data: account, message: 'Cuenta creada exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateAccount(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateAccountSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de cuenta inválidos', parseResult.error.errors); + } + const dto: UpdateAccountDto = parseResult.data; + const account = await accountsService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + res.json({ success: true, data: account, message: 'Cuenta actualizada exitosamente' }); + } catch (error) { + next(error); + } + } + + async deleteAccount(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await accountsService.delete(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, message: 'Cuenta eliminada exitosamente' }); + } catch (error) { + next(error); + } + } + + async getAccountBalance(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const balance = await accountsService.getBalance(req.params.id, req.tenantId!); + res.json({ success: true, data: balance }); + } catch (error) { + next(error); + } + } + + // ========== JOURNALS ========== + async getJournals(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = journalQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + const filters: JournalFilters = queryResult.data; + const result = await journalsService.findAll(req.tenantId!, filters); + res.json({ + success: true, + data: result.data, + meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 50)) }, + }); + } catch (error) { + next(error); + } + } + + async getJournal(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const journal = await journalsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: journal }); + } catch (error) { + next(error); + } + } + + async createJournal(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createJournalSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de diario inválidos', parseResult.error.errors); + } + const dto: CreateJournalDto = parseResult.data; + const journal = await journalsService.create(dto, req.tenantId!, req.user!.userId); + res.status(201).json({ success: true, data: journal, message: 'Diario creado exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateJournal(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateJournalSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de diario inválidos', parseResult.error.errors); + } + const dto: UpdateJournalDto = parseResult.data; + const journal = await journalsService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + res.json({ success: true, data: journal, message: 'Diario actualizado exitosamente' }); + } catch (error) { + next(error); + } + } + + async deleteJournal(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await journalsService.delete(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, message: 'Diario eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== JOURNAL ENTRIES ========== + async getJournalEntries(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = journalEntryQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + const filters: JournalEntryFilters = queryResult.data; + const result = await journalEntriesService.findAll(req.tenantId!, filters); + res.json({ + success: true, + data: result.data, + meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) }, + }); + } catch (error) { + next(error); + } + } + + async getJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const entry = await journalEntriesService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: entry }); + } catch (error) { + next(error); + } + } + + async createJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createJournalEntrySchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de póliza inválidos', parseResult.error.errors); + } + const dto: CreateJournalEntryDto = parseResult.data; + const entry = await journalEntriesService.create(dto, req.tenantId!, req.user!.userId); + res.status(201).json({ success: true, data: entry, message: 'Póliza creada exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateJournalEntrySchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de póliza inválidos', parseResult.error.errors); + } + const dto: UpdateJournalEntryDto = parseResult.data; + const entry = await journalEntriesService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + res.json({ success: true, data: entry, message: 'Póliza actualizada exitosamente' }); + } catch (error) { + next(error); + } + } + + async postJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const entry = await journalEntriesService.post(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: entry, message: 'Póliza publicada exitosamente' }); + } catch (error) { + next(error); + } + } + + async cancelJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const entry = await journalEntriesService.cancel(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: entry, message: 'Póliza cancelada exitosamente' }); + } catch (error) { + next(error); + } + } + + async deleteJournalEntry(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await journalEntriesService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Póliza eliminada exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== INVOICES ========== + async getInvoices(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = invoiceQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + const filters: InvoiceFilters = queryResult.data; + const result = await invoicesService.findAll(req.tenantId!, filters); + res.json({ + success: true, + data: result.data, + meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) }, + }); + } catch (error) { + next(error); + } + } + + async getInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const invoice = await invoicesService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: invoice }); + } catch (error) { + next(error); + } + } + + async createInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createInvoiceSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de factura inválidos', parseResult.error.errors); + } + const dto: CreateInvoiceDto = parseResult.data; + const invoice = await invoicesService.create(dto, req.tenantId!, req.user!.userId); + res.status(201).json({ success: true, data: invoice, message: 'Factura creada exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateInvoiceSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de factura inválidos', parseResult.error.errors); + } + const dto: UpdateInvoiceDto = parseResult.data; + const invoice = await invoicesService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + res.json({ success: true, data: invoice, message: 'Factura actualizada exitosamente' }); + } catch (error) { + next(error); + } + } + + async validateInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const invoice = await invoicesService.validate(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: invoice, message: 'Factura validada exitosamente' }); + } catch (error) { + next(error); + } + } + + async cancelInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const invoice = await invoicesService.cancel(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: invoice, message: 'Factura cancelada exitosamente' }); + } catch (error) { + next(error); + } + } + + async deleteInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await invoicesService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Factura eliminada exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== INVOICE LINES ========== + async addInvoiceLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createInvoiceLineSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de línea inválidos', parseResult.error.errors); + } + const dto: CreateInvoiceLineDto = parseResult.data; + const line = await invoicesService.addLine(req.params.id, dto, req.tenantId!); + res.status(201).json({ success: true, data: line, message: 'Línea agregada exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateInvoiceLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateInvoiceLineSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de línea inválidos', parseResult.error.errors); + } + const dto: UpdateInvoiceLineDto = parseResult.data; + const line = await invoicesService.updateLine(req.params.id, req.params.lineId, dto, req.tenantId!); + res.json({ success: true, data: line, message: 'Línea actualizada exitosamente' }); + } catch (error) { + next(error); + } + } + + async removeInvoiceLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await invoicesService.removeLine(req.params.id, req.params.lineId, req.tenantId!); + res.json({ success: true, message: 'Línea eliminada exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== PAYMENTS ========== + async getPayments(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = paymentQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + const filters: PaymentFilters = queryResult.data; + const result = await paymentsService.findAll(req.tenantId!, filters); + res.json({ + success: true, + data: result.data, + meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) }, + }); + } catch (error) { + next(error); + } + } + + async getPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const payment = await paymentsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: payment }); + } catch (error) { + next(error); + } + } + + async createPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createPaymentSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de pago inválidos', parseResult.error.errors); + } + const dto: CreatePaymentDto = parseResult.data; + const payment = await paymentsService.create(dto, req.tenantId!, req.user!.userId); + res.status(201).json({ success: true, data: payment, message: 'Pago creado exitosamente' }); + } catch (error) { + next(error); + } + } + + async updatePayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updatePaymentSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de pago inválidos', parseResult.error.errors); + } + const dto: UpdatePaymentDto = parseResult.data; + const payment = await paymentsService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + res.json({ success: true, data: payment, message: 'Pago actualizado exitosamente' }); + } catch (error) { + next(error); + } + } + + async postPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const payment = await paymentsService.post(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: payment, message: 'Pago publicado exitosamente' }); + } catch (error) { + next(error); + } + } + + async reconcilePayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = reconcilePaymentSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de conciliación inválidos', parseResult.error.errors); + } + const dto: ReconcileDto = parseResult.data; + const payment = await paymentsService.reconcile(req.params.id, dto, req.tenantId!, req.user!.userId); + res.json({ success: true, data: payment, message: 'Pago conciliado exitosamente' }); + } catch (error) { + next(error); + } + } + + async cancelPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const payment = await paymentsService.cancel(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: payment, message: 'Pago cancelado exitosamente' }); + } catch (error) { + next(error); + } + } + + async deletePayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await paymentsService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Pago eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== TAXES ========== + async getTaxes(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = taxQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + const filters: TaxFilters = queryResult.data; + const result = await taxesService.findAll(req.tenantId!, filters); + res.json({ + success: true, + data: result.data, + meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) }, + }); + } catch (error) { + next(error); + } + } + + async getTax(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tax = await taxesService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: tax }); + } catch (error) { + next(error); + } + } + + async createTax(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createTaxSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de impuesto inválidos', parseResult.error.errors); + } + const dto: CreateTaxDto = parseResult.data; + const tax = await taxesService.create(dto, req.tenantId!, req.user!.userId); + res.status(201).json({ success: true, data: tax, message: 'Impuesto creado exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateTax(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateTaxSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de impuesto inválidos', parseResult.error.errors); + } + const dto: UpdateTaxDto = parseResult.data; + const tax = await taxesService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + res.json({ success: true, data: tax, message: 'Impuesto actualizado exitosamente' }); + } catch (error) { + next(error); + } + } + + async deleteTax(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await taxesService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Impuesto eliminado exitosamente' }); + } catch (error) { + next(error); + } + } +} + +export const financialController = new FinancialController(); diff --git a/src/modules/financial/financial.routes.ts b/src/modules/financial/financial.routes.ts new file mode 100644 index 0000000..8a18e65 --- /dev/null +++ b/src/modules/financial/financial.routes.ts @@ -0,0 +1,150 @@ +import { Router } from 'express'; +import { financialController } from './financial.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// ========== ACCOUNT TYPES ========== +router.get('/account-types', (req, res, next) => financialController.getAccountTypes(req, res, next)); + +// ========== ACCOUNTS ========== +router.get('/accounts', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) => + financialController.getAccounts(req, res, next) +); +router.get('/accounts/:id', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) => + financialController.getAccount(req, res, next) +); +router.get('/accounts/:id/balance', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) => + financialController.getAccountBalance(req, res, next) +); +router.post('/accounts', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) => + financialController.createAccount(req, res, next) +); +router.put('/accounts/:id', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) => + financialController.updateAccount(req, res, next) +); +router.delete('/accounts/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + financialController.deleteAccount(req, res, next) +); + +// ========== JOURNALS ========== +router.get('/journals', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) => + financialController.getJournals(req, res, next) +); +router.get('/journals/:id', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) => + financialController.getJournal(req, res, next) +); +router.post('/journals', requireRoles('admin', 'super_admin'), (req, res, next) => + financialController.createJournal(req, res, next) +); +router.put('/journals/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + financialController.updateJournal(req, res, next) +); +router.delete('/journals/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + financialController.deleteJournal(req, res, next) +); + +// ========== JOURNAL ENTRIES ========== +router.get('/entries', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) => + financialController.getJournalEntries(req, res, next) +); +router.get('/entries/:id', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) => + financialController.getJournalEntry(req, res, next) +); +router.post('/entries', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) => + financialController.createJournalEntry(req, res, next) +); +router.put('/entries/:id', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) => + financialController.updateJournalEntry(req, res, next) +); +router.post('/entries/:id/post', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) => + financialController.postJournalEntry(req, res, next) +); +router.post('/entries/:id/cancel', requireRoles('admin', 'super_admin'), (req, res, next) => + financialController.cancelJournalEntry(req, res, next) +); +router.delete('/entries/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + financialController.deleteJournalEntry(req, res, next) +); + +// ========== INVOICES ========== +router.get('/invoices', requireRoles('admin', 'accountant', 'manager', 'sales', 'super_admin'), (req, res, next) => + financialController.getInvoices(req, res, next) +); +router.get('/invoices/:id', requireRoles('admin', 'accountant', 'manager', 'sales', 'super_admin'), (req, res, next) => + financialController.getInvoice(req, res, next) +); +router.post('/invoices', requireRoles('admin', 'accountant', 'sales', 'super_admin'), (req, res, next) => + financialController.createInvoice(req, res, next) +); +router.put('/invoices/:id', requireRoles('admin', 'accountant', 'sales', 'super_admin'), (req, res, next) => + financialController.updateInvoice(req, res, next) +); +router.post('/invoices/:id/validate', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) => + financialController.validateInvoice(req, res, next) +); +router.post('/invoices/:id/cancel', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) => + financialController.cancelInvoice(req, res, next) +); +router.delete('/invoices/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + financialController.deleteInvoice(req, res, next) +); + +// Invoice lines +router.post('/invoices/:id/lines', requireRoles('admin', 'accountant', 'sales', 'super_admin'), (req, res, next) => + financialController.addInvoiceLine(req, res, next) +); +router.put('/invoices/:id/lines/:lineId', requireRoles('admin', 'accountant', 'sales', 'super_admin'), (req, res, next) => + financialController.updateInvoiceLine(req, res, next) +); +router.delete('/invoices/:id/lines/:lineId', requireRoles('admin', 'accountant', 'sales', 'super_admin'), (req, res, next) => + financialController.removeInvoiceLine(req, res, next) +); + +// ========== PAYMENTS ========== +router.get('/payments', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) => + financialController.getPayments(req, res, next) +); +router.get('/payments/:id', requireRoles('admin', 'accountant', 'manager', 'super_admin'), (req, res, next) => + financialController.getPayment(req, res, next) +); +router.post('/payments', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) => + financialController.createPayment(req, res, next) +); +router.put('/payments/:id', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) => + financialController.updatePayment(req, res, next) +); +router.post('/payments/:id/post', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) => + financialController.postPayment(req, res, next) +); +router.post('/payments/:id/reconcile', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) => + financialController.reconcilePayment(req, res, next) +); +router.post('/payments/:id/cancel', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) => + financialController.cancelPayment(req, res, next) +); +router.delete('/payments/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + financialController.deletePayment(req, res, next) +); + +// ========== TAXES ========== +router.get('/taxes', requireRoles('admin', 'accountant', 'manager', 'sales', 'super_admin'), (req, res, next) => + financialController.getTaxes(req, res, next) +); +router.get('/taxes/:id', requireRoles('admin', 'accountant', 'manager', 'sales', 'super_admin'), (req, res, next) => + financialController.getTax(req, res, next) +); +router.post('/taxes', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) => + financialController.createTax(req, res, next) +); +router.put('/taxes/:id', requireRoles('admin', 'accountant', 'super_admin'), (req, res, next) => + financialController.updateTax(req, res, next) +); +router.delete('/taxes/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + financialController.deleteTax(req, res, next) +); + +export default router; diff --git a/src/modules/financial/fiscalPeriods.service.ts b/src/modules/financial/fiscalPeriods.service.ts new file mode 100644 index 0000000..f286cba --- /dev/null +++ b/src/modules/financial/fiscalPeriods.service.ts @@ -0,0 +1,369 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export type FiscalPeriodStatus = 'open' | 'closed'; + +export interface FiscalYear { + id: string; + tenant_id: string; + company_id: string; + name: string; + code: string; + date_from: Date; + date_to: Date; + status: FiscalPeriodStatus; + created_at: Date; +} + +export interface FiscalPeriod { + id: string; + tenant_id: string; + fiscal_year_id: string; + fiscal_year_name?: string; + code: string; + name: string; + date_from: Date; + date_to: Date; + status: FiscalPeriodStatus; + closed_at: Date | null; + closed_by: string | null; + closed_by_name?: string; + created_at: Date; +} + +export interface CreateFiscalYearDto { + company_id: string; + name: string; + code: string; + date_from: string; + date_to: string; +} + +export interface CreateFiscalPeriodDto { + fiscal_year_id: string; + code: string; + name: string; + date_from: string; + date_to: string; +} + +export interface FiscalPeriodFilters { + company_id?: string; + fiscal_year_id?: string; + status?: FiscalPeriodStatus; + date_from?: string; + date_to?: string; +} + +// ============================================================================ +// SERVICE +// ============================================================================ + +class FiscalPeriodsService { + // ==================== FISCAL YEARS ==================== + + async findAllYears(tenantId: string, companyId?: string): Promise { + let sql = ` + SELECT * FROM financial.fiscal_years + WHERE tenant_id = $1 + `; + const params: any[] = [tenantId]; + + if (companyId) { + sql += ` AND company_id = $2`; + params.push(companyId); + } + + sql += ` ORDER BY date_from DESC`; + + return query(sql, params); + } + + async findYearById(id: string, tenantId: string): Promise { + const year = await queryOne( + `SELECT * FROM financial.fiscal_years WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + if (!year) { + throw new NotFoundError('Año fiscal no encontrado'); + } + + return year; + } + + async createYear(dto: CreateFiscalYearDto, tenantId: string, userId: string): Promise { + // Check for overlapping years + const overlapping = await queryOne<{ id: string }>( + `SELECT id FROM financial.fiscal_years + WHERE tenant_id = $1 AND company_id = $2 + AND (date_from, date_to) OVERLAPS ($3::date, $4::date)`, + [tenantId, dto.company_id, dto.date_from, dto.date_to] + ); + + if (overlapping) { + throw new ConflictError('Ya existe un año fiscal que se superpone con estas fechas'); + } + + const year = await queryOne( + `INSERT INTO financial.fiscal_years ( + tenant_id, company_id, name, code, date_from, date_to, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [tenantId, dto.company_id, dto.name, dto.code, dto.date_from, dto.date_to, userId] + ); + + logger.info('Fiscal year created', { yearId: year?.id, name: dto.name }); + + return year!; + } + + // ==================== FISCAL PERIODS ==================== + + async findAllPeriods(tenantId: string, filters: FiscalPeriodFilters = {}): Promise { + const conditions: string[] = ['fp.tenant_id = $1']; + const params: any[] = [tenantId]; + let idx = 2; + + if (filters.fiscal_year_id) { + conditions.push(`fp.fiscal_year_id = $${idx++}`); + params.push(filters.fiscal_year_id); + } + + if (filters.company_id) { + conditions.push(`fy.company_id = $${idx++}`); + params.push(filters.company_id); + } + + if (filters.status) { + conditions.push(`fp.status = $${idx++}`); + params.push(filters.status); + } + + if (filters.date_from) { + conditions.push(`fp.date_from >= $${idx++}`); + params.push(filters.date_from); + } + + if (filters.date_to) { + conditions.push(`fp.date_to <= $${idx++}`); + params.push(filters.date_to); + } + + return query( + `SELECT fp.*, + fy.name as fiscal_year_name, + u.full_name as closed_by_name + FROM financial.fiscal_periods fp + JOIN financial.fiscal_years fy ON fp.fiscal_year_id = fy.id + LEFT JOIN auth.users u ON fp.closed_by = u.id + WHERE ${conditions.join(' AND ')} + ORDER BY fp.date_from DESC`, + params + ); + } + + async findPeriodById(id: string, tenantId: string): Promise { + const period = await queryOne( + `SELECT fp.*, + fy.name as fiscal_year_name, + u.full_name as closed_by_name + FROM financial.fiscal_periods fp + JOIN financial.fiscal_years fy ON fp.fiscal_year_id = fy.id + LEFT JOIN auth.users u ON fp.closed_by = u.id + WHERE fp.id = $1 AND fp.tenant_id = $2`, + [id, tenantId] + ); + + if (!period) { + throw new NotFoundError('Período fiscal no encontrado'); + } + + return period; + } + + async findPeriodByDate(date: Date, companyId: string, tenantId: string): Promise { + return queryOne( + `SELECT fp.* + FROM financial.fiscal_periods fp + JOIN financial.fiscal_years fy ON fp.fiscal_year_id = fy.id + WHERE fp.tenant_id = $1 + AND fy.company_id = $2 + AND $3::date BETWEEN fp.date_from AND fp.date_to`, + [tenantId, companyId, date] + ); + } + + async createPeriod(dto: CreateFiscalPeriodDto, tenantId: string, userId: string): Promise { + // Verify fiscal year exists + await this.findYearById(dto.fiscal_year_id, tenantId); + + // Check for overlapping periods in the same year + const overlapping = await queryOne<{ id: string }>( + `SELECT id FROM financial.fiscal_periods + WHERE tenant_id = $1 AND fiscal_year_id = $2 + AND (date_from, date_to) OVERLAPS ($3::date, $4::date)`, + [tenantId, dto.fiscal_year_id, dto.date_from, dto.date_to] + ); + + if (overlapping) { + throw new ConflictError('Ya existe un período que se superpone con estas fechas'); + } + + const period = await queryOne( + `INSERT INTO financial.fiscal_periods ( + tenant_id, fiscal_year_id, code, name, date_from, date_to, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [tenantId, dto.fiscal_year_id, dto.code, dto.name, dto.date_from, dto.date_to, userId] + ); + + logger.info('Fiscal period created', { periodId: period?.id, name: dto.name }); + + return period!; + } + + // ==================== PERIOD OPERATIONS ==================== + + /** + * Close a fiscal period + * Uses database function for validation + */ + async closePeriod(periodId: string, tenantId: string, userId: string): Promise { + // Verify period exists and belongs to tenant + await this.findPeriodById(periodId, tenantId); + + // Use database function for atomic close with validations + const result = await queryOne( + `SELECT * FROM financial.close_fiscal_period($1, $2)`, + [periodId, userId] + ); + + if (!result) { + throw new Error('Error al cerrar período'); + } + + logger.info('Fiscal period closed', { periodId, userId }); + + return result; + } + + /** + * Reopen a fiscal period (admin only) + */ + async reopenPeriod(periodId: string, tenantId: string, userId: string, reason?: string): Promise { + // Verify period exists and belongs to tenant + await this.findPeriodById(periodId, tenantId); + + // Use database function for atomic reopen with audit + const result = await queryOne( + `SELECT * FROM financial.reopen_fiscal_period($1, $2, $3)`, + [periodId, userId, reason] + ); + + if (!result) { + throw new Error('Error al reabrir período'); + } + + logger.warn('Fiscal period reopened', { periodId, userId, reason }); + + return result; + } + + /** + * Get statistics for a period + */ + async getPeriodStats(periodId: string, tenantId: string): Promise<{ + total_entries: number; + draft_entries: number; + posted_entries: number; + total_debit: number; + total_credit: number; + }> { + const stats = await queryOne<{ + total_entries: string; + draft_entries: string; + posted_entries: string; + total_debit: string; + total_credit: string; + }>( + `SELECT + COUNT(*) as total_entries, + COUNT(*) FILTER (WHERE status = 'draft') as draft_entries, + COUNT(*) FILTER (WHERE status = 'posted') as posted_entries, + COALESCE(SUM(total_debit), 0) as total_debit, + COALESCE(SUM(total_credit), 0) as total_credit + FROM financial.journal_entries + WHERE fiscal_period_id = $1 AND tenant_id = $2`, + [periodId, tenantId] + ); + + return { + total_entries: parseInt(stats?.total_entries || '0', 10), + draft_entries: parseInt(stats?.draft_entries || '0', 10), + posted_entries: parseInt(stats?.posted_entries || '0', 10), + total_debit: parseFloat(stats?.total_debit || '0'), + total_credit: parseFloat(stats?.total_credit || '0'), + }; + } + + /** + * Generate monthly periods for a fiscal year + */ + async generateMonthlyPeriods(fiscalYearId: string, tenantId: string, userId: string): Promise { + const year = await this.findYearById(fiscalYearId, tenantId); + + const startDate = new Date(year.date_from); + const endDate = new Date(year.date_to); + const periods: FiscalPeriod[] = []; + + let currentDate = new Date(startDate); + let periodNum = 1; + + while (currentDate <= endDate) { + const periodStart = new Date(currentDate); + const periodEnd = new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0); + + // Don't exceed the fiscal year end + if (periodEnd > endDate) { + periodEnd.setTime(endDate.getTime()); + } + + const monthNames = [ + 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', + 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre' + ]; + + try { + const period = await this.createPeriod({ + fiscal_year_id: fiscalYearId, + code: String(periodNum).padStart(2, '0'), + name: `${monthNames[periodStart.getMonth()]} ${periodStart.getFullYear()}`, + date_from: periodStart.toISOString().split('T')[0], + date_to: periodEnd.toISOString().split('T')[0], + }, tenantId, userId); + + periods.push(period); + } catch (error) { + // Skip if period already exists (overlapping check will fail) + logger.debug('Period creation skipped', { periodNum, error }); + } + + // Move to next month + currentDate.setMonth(currentDate.getMonth() + 1); + currentDate.setDate(1); + periodNum++; + } + + logger.info('Generated monthly periods', { fiscalYearId, count: periods.length }); + + return periods; + } +} + +export const fiscalPeriodsService = new FiscalPeriodsService(); diff --git a/src/modules/financial/index.ts b/src/modules/financial/index.ts new file mode 100644 index 0000000..3cb9206 --- /dev/null +++ b/src/modules/financial/index.ts @@ -0,0 +1,8 @@ +export * from './accounts.service.js'; +export * from './journals.service.js'; +export * from './journal-entries.service.js'; +export * from './invoices.service.js'; +export * from './payments.service.js'; +export * from './taxes.service.js'; +export * from './financial.controller.js'; +export { default as financialRoutes } from './financial.routes.js'; diff --git a/src/modules/financial/invoices.service.ts b/src/modules/financial/invoices.service.ts new file mode 100644 index 0000000..cace96a --- /dev/null +++ b/src/modules/financial/invoices.service.ts @@ -0,0 +1,547 @@ +import { query, queryOne, getClient } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; +import { taxesService } from './taxes.service.js'; + +export interface InvoiceLine { + id: string; + invoice_id: string; + product_id?: string; + product_name?: string; + description: string; + quantity: number; + uom_id?: string; + uom_name?: string; + price_unit: number; + tax_ids: string[]; + amount_untaxed: number; + amount_tax: number; + amount_total: number; + account_id?: string; + account_name?: string; +} + +export interface Invoice { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + partner_id: string; + partner_name?: string; + invoice_type: 'customer' | 'supplier'; + number?: string; + ref?: string; + invoice_date: Date; + due_date?: Date; + currency_id: string; + currency_code?: string; + amount_untaxed: number; + amount_tax: number; + amount_total: number; + amount_paid: number; + amount_residual: number; + status: 'draft' | 'open' | 'paid' | 'cancelled'; + payment_term_id?: string; + journal_id?: string; + journal_entry_id?: string; + notes?: string; + lines?: InvoiceLine[]; + created_at: Date; + validated_at?: Date; +} + +export interface CreateInvoiceDto { + company_id: string; + partner_id: string; + invoice_type: 'customer' | 'supplier'; + ref?: string; + invoice_date?: string; + due_date?: string; + currency_id: string; + payment_term_id?: string; + journal_id?: string; + notes?: string; +} + +export interface UpdateInvoiceDto { + partner_id?: string; + ref?: string | null; + invoice_date?: string; + due_date?: string | null; + currency_id?: string; + payment_term_id?: string | null; + journal_id?: string | null; + notes?: string | null; +} + +export interface CreateInvoiceLineDto { + product_id?: string; + description: string; + quantity: number; + uom_id?: string; + price_unit: number; + tax_ids?: string[]; + account_id?: string; +} + +export interface UpdateInvoiceLineDto { + product_id?: string | null; + description?: string; + quantity?: number; + uom_id?: string | null; + price_unit?: number; + tax_ids?: string[]; + account_id?: string | null; +} + +export interface InvoiceFilters { + company_id?: string; + partner_id?: string; + invoice_type?: string; + status?: string; + date_from?: string; + date_to?: string; + search?: string; + page?: number; + limit?: number; +} + +class InvoicesService { + async findAll(tenantId: string, filters: InvoiceFilters = {}): Promise<{ data: Invoice[]; total: number }> { + const { company_id, partner_id, invoice_type, status, date_from, date_to, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE i.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND i.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (partner_id) { + whereClause += ` AND i.partner_id = $${paramIndex++}`; + params.push(partner_id); + } + + if (invoice_type) { + whereClause += ` AND i.invoice_type = $${paramIndex++}`; + params.push(invoice_type); + } + + if (status) { + whereClause += ` AND i.status = $${paramIndex++}`; + params.push(status); + } + + if (date_from) { + whereClause += ` AND i.invoice_date >= $${paramIndex++}`; + params.push(date_from); + } + + if (date_to) { + whereClause += ` AND i.invoice_date <= $${paramIndex++}`; + params.push(date_to); + } + + if (search) { + whereClause += ` AND (i.number ILIKE $${paramIndex} OR i.ref ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count + FROM financial.invoices i + LEFT JOIN core.partners p ON i.partner_id = p.id + ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT i.*, + c.name as company_name, + p.name as partner_name, + cu.code as currency_code + FROM financial.invoices i + LEFT JOIN auth.companies c ON i.company_id = c.id + LEFT JOIN core.partners p ON i.partner_id = p.id + LEFT JOIN core.currencies cu ON i.currency_id = cu.id + ${whereClause} + ORDER BY i.invoice_date DESC, i.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const invoice = await queryOne( + `SELECT i.*, + c.name as company_name, + p.name as partner_name, + cu.code as currency_code + FROM financial.invoices i + LEFT JOIN auth.companies c ON i.company_id = c.id + LEFT JOIN core.partners p ON i.partner_id = p.id + LEFT JOIN core.currencies cu ON i.currency_id = cu.id + WHERE i.id = $1 AND i.tenant_id = $2`, + [id, tenantId] + ); + + if (!invoice) { + throw new NotFoundError('Factura no encontrada'); + } + + // Get lines + const lines = await query( + `SELECT il.*, + pr.name as product_name, + um.name as uom_name, + a.name as account_name + FROM financial.invoice_lines il + LEFT JOIN inventory.products pr ON il.product_id = pr.id + LEFT JOIN core.uom um ON il.uom_id = um.id + LEFT JOIN financial.accounts a ON il.account_id = a.id + WHERE il.invoice_id = $1 + ORDER BY il.created_at`, + [id] + ); + + invoice.lines = lines; + + return invoice; + } + + async create(dto: CreateInvoiceDto, tenantId: string, userId: string): Promise { + const invoiceDate = dto.invoice_date || new Date().toISOString().split('T')[0]; + + const invoice = await queryOne( + `INSERT INTO financial.invoices ( + tenant_id, company_id, partner_id, invoice_type, ref, invoice_date, + due_date, currency_id, payment_term_id, journal_id, notes, + amount_untaxed, amount_tax, amount_total, amount_paid, amount_residual, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 0, 0, 0, 0, 0, $12) + RETURNING *`, + [ + tenantId, dto.company_id, dto.partner_id, dto.invoice_type, dto.ref, + invoiceDate, dto.due_date, dto.currency_id, dto.payment_term_id, + dto.journal_id, dto.notes, userId + ] + ); + + return invoice!; + } + + async update(id: string, dto: UpdateInvoiceDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'draft') { + throw new ValidationError('Solo se pueden editar facturas en estado borrador'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.partner_id !== undefined) { + updateFields.push(`partner_id = $${paramIndex++}`); + values.push(dto.partner_id); + } + if (dto.ref !== undefined) { + updateFields.push(`ref = $${paramIndex++}`); + values.push(dto.ref); + } + if (dto.invoice_date !== undefined) { + updateFields.push(`invoice_date = $${paramIndex++}`); + values.push(dto.invoice_date); + } + if (dto.due_date !== undefined) { + updateFields.push(`due_date = $${paramIndex++}`); + values.push(dto.due_date); + } + if (dto.currency_id !== undefined) { + updateFields.push(`currency_id = $${paramIndex++}`); + values.push(dto.currency_id); + } + if (dto.payment_term_id !== undefined) { + updateFields.push(`payment_term_id = $${paramIndex++}`); + values.push(dto.payment_term_id); + } + if (dto.journal_id !== undefined) { + updateFields.push(`journal_id = $${paramIndex++}`); + values.push(dto.journal_id); + } + if (dto.notes !== undefined) { + updateFields.push(`notes = $${paramIndex++}`); + values.push(dto.notes); + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + await query( + `UPDATE financial.invoices SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'draft') { + throw new ValidationError('Solo se pueden eliminar facturas en estado borrador'); + } + + await query( + `DELETE FROM financial.invoices WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + } + + async addLine(invoiceId: string, dto: CreateInvoiceLineDto, tenantId: string): Promise { + const invoice = await this.findById(invoiceId, tenantId); + + if (invoice.status !== 'draft') { + throw new ValidationError('Solo se pueden agregar líneas a facturas en estado borrador'); + } + + // Calculate amounts with taxes using taxesService + // Determine transaction type based on invoice type + const transactionType = invoice.invoice_type === 'customer' + ? 'sales' + : 'purchase'; + + const taxResult = await taxesService.calculateTaxes( + { + quantity: dto.quantity, + priceUnit: dto.price_unit, + discount: 0, // Invoices don't have line discounts by default + taxIds: dto.tax_ids || [], + }, + tenantId, + transactionType + ); + const amountUntaxed = taxResult.amountUntaxed; + const amountTax = taxResult.amountTax; + const amountTotal = taxResult.amountTotal; + + const line = await queryOne( + `INSERT INTO financial.invoice_lines ( + invoice_id, tenant_id, product_id, description, quantity, uom_id, + price_unit, tax_ids, amount_untaxed, amount_tax, amount_total, account_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING *`, + [ + invoiceId, tenantId, dto.product_id, dto.description, dto.quantity, dto.uom_id, + dto.price_unit, dto.tax_ids || [], amountUntaxed, amountTax, amountTotal, dto.account_id + ] + ); + + // Update invoice totals + await this.updateTotals(invoiceId); + + return line!; + } + + async updateLine(invoiceId: string, lineId: string, dto: UpdateInvoiceLineDto, tenantId: string): Promise { + const invoice = await this.findById(invoiceId, tenantId); + + if (invoice.status !== 'draft') { + throw new ValidationError('Solo se pueden editar líneas de facturas en estado borrador'); + } + + const existingLine = invoice.lines?.find(l => l.id === lineId); + if (!existingLine) { + throw new NotFoundError('Línea de factura no encontrada'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + const quantity = dto.quantity ?? existingLine.quantity; + const priceUnit = dto.price_unit ?? existingLine.price_unit; + + if (dto.product_id !== undefined) { + updateFields.push(`product_id = $${paramIndex++}`); + values.push(dto.product_id); + } + if (dto.description !== undefined) { + updateFields.push(`description = $${paramIndex++}`); + values.push(dto.description); + } + if (dto.quantity !== undefined) { + updateFields.push(`quantity = $${paramIndex++}`); + values.push(dto.quantity); + } + if (dto.uom_id !== undefined) { + updateFields.push(`uom_id = $${paramIndex++}`); + values.push(dto.uom_id); + } + if (dto.price_unit !== undefined) { + updateFields.push(`price_unit = $${paramIndex++}`); + values.push(dto.price_unit); + } + if (dto.tax_ids !== undefined) { + updateFields.push(`tax_ids = $${paramIndex++}`); + values.push(dto.tax_ids); + } + if (dto.account_id !== undefined) { + updateFields.push(`account_id = $${paramIndex++}`); + values.push(dto.account_id); + } + + // Recalculate amounts + const amountUntaxed = quantity * priceUnit; + const amountTax = 0; // TODO: Calculate taxes + const amountTotal = amountUntaxed + amountTax; + + updateFields.push(`amount_untaxed = $${paramIndex++}`); + values.push(amountUntaxed); + updateFields.push(`amount_tax = $${paramIndex++}`); + values.push(amountTax); + updateFields.push(`amount_total = $${paramIndex++}`); + values.push(amountTotal); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(lineId, invoiceId); + + await query( + `UPDATE financial.invoice_lines SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND invoice_id = $${paramIndex}`, + values + ); + + // Update invoice totals + await this.updateTotals(invoiceId); + + const updated = await queryOne( + `SELECT * FROM financial.invoice_lines WHERE id = $1`, + [lineId] + ); + + return updated!; + } + + async removeLine(invoiceId: string, lineId: string, tenantId: string): Promise { + const invoice = await this.findById(invoiceId, tenantId); + + if (invoice.status !== 'draft') { + throw new ValidationError('Solo se pueden eliminar líneas de facturas en estado borrador'); + } + + await query( + `DELETE FROM financial.invoice_lines WHERE id = $1 AND invoice_id = $2`, + [lineId, invoiceId] + ); + + // Update invoice totals + await this.updateTotals(invoiceId); + } + + async validate(id: string, tenantId: string, userId: string): Promise { + const invoice = await this.findById(id, tenantId); + + if (invoice.status !== 'draft') { + throw new ValidationError('Solo se pueden validar facturas en estado borrador'); + } + + if (!invoice.lines || invoice.lines.length === 0) { + throw new ValidationError('La factura debe tener al menos una línea'); + } + + // Generate invoice number + const prefix = invoice.invoice_type === 'customer' ? 'INV' : 'BILL'; + const seqResult = await queryOne<{ next_num: number }>( + `SELECT COALESCE(MAX(CAST(SUBSTRING(number FROM 5) AS INTEGER)), 0) + 1 as next_num + FROM financial.invoices WHERE tenant_id = $1 AND number LIKE '${prefix}-%'`, + [tenantId] + ); + const invoiceNumber = `${prefix}-${String(seqResult?.next_num || 1).padStart(6, '0')}`; + + await query( + `UPDATE financial.invoices SET + number = $1, + status = 'open', + amount_residual = amount_total, + validated_at = CURRENT_TIMESTAMP, + validated_by = $2, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $3 AND tenant_id = $4`, + [invoiceNumber, userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async cancel(id: string, tenantId: string, userId: string): Promise { + const invoice = await this.findById(id, tenantId); + + if (invoice.status === 'paid') { + throw new ValidationError('No se pueden cancelar facturas pagadas'); + } + + if (invoice.status === 'cancelled') { + throw new ValidationError('La factura ya está cancelada'); + } + + if (invoice.amount_paid > 0) { + throw new ValidationError('No se puede cancelar: la factura tiene pagos asociados'); + } + + await query( + `UPDATE financial.invoices SET + status = 'cancelled', + cancelled_at = CURRENT_TIMESTAMP, + cancelled_by = $1, + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + private async updateTotals(invoiceId: string): Promise { + const totals = await queryOne<{ amount_untaxed: number; amount_tax: number; amount_total: number }>( + `SELECT + COALESCE(SUM(amount_untaxed), 0) as amount_untaxed, + COALESCE(SUM(amount_tax), 0) as amount_tax, + COALESCE(SUM(amount_total), 0) as amount_total + FROM financial.invoice_lines WHERE invoice_id = $1`, + [invoiceId] + ); + + await query( + `UPDATE financial.invoices SET + amount_untaxed = $1, + amount_tax = $2, + amount_total = $3, + amount_residual = $3 - amount_paid + WHERE id = $4`, + [totals?.amount_untaxed || 0, totals?.amount_tax || 0, totals?.amount_total || 0, invoiceId] + ); + } +} + +export const invoicesService = new InvoicesService(); diff --git a/src/modules/financial/journal-entries.service.ts b/src/modules/financial/journal-entries.service.ts new file mode 100644 index 0000000..1469e05 --- /dev/null +++ b/src/modules/financial/journal-entries.service.ts @@ -0,0 +1,343 @@ +import { query, queryOne, getClient } from '../../config/database.js'; +import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js'; + +export type EntryStatus = 'draft' | 'posted' | 'cancelled'; + +export interface JournalEntryLine { + id?: string; + account_id: string; + account_name?: string; + account_code?: string; + partner_id?: string; + partner_name?: string; + debit: number; + credit: number; + description?: string; + ref?: string; +} + +export interface JournalEntry { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + journal_id: string; + journal_name?: string; + name: string; + ref?: string; + date: Date; + status: EntryStatus; + notes?: string; + lines?: JournalEntryLine[]; + total_debit?: number; + total_credit?: number; + created_at: Date; + posted_at?: Date; +} + +export interface CreateJournalEntryDto { + company_id: string; + journal_id: string; + name: string; + ref?: string; + date: string; + notes?: string; + lines: Omit[]; +} + +export interface UpdateJournalEntryDto { + ref?: string | null; + date?: string; + notes?: string | null; + lines?: Omit[]; +} + +export interface JournalEntryFilters { + company_id?: string; + journal_id?: string; + status?: EntryStatus; + date_from?: string; + date_to?: string; + search?: string; + page?: number; + limit?: number; +} + +class JournalEntriesService { + async findAll(tenantId: string, filters: JournalEntryFilters = {}): Promise<{ data: JournalEntry[]; total: number }> { + const { company_id, journal_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE je.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND je.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (journal_id) { + whereClause += ` AND je.journal_id = $${paramIndex++}`; + params.push(journal_id); + } + + if (status) { + whereClause += ` AND je.status = $${paramIndex++}`; + params.push(status); + } + + if (date_from) { + whereClause += ` AND je.date >= $${paramIndex++}`; + params.push(date_from); + } + + if (date_to) { + whereClause += ` AND je.date <= $${paramIndex++}`; + params.push(date_to); + } + + if (search) { + whereClause += ` AND (je.name ILIKE $${paramIndex} OR je.ref ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM financial.journal_entries je ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT je.*, + c.name as company_name, + j.name as journal_name, + (SELECT COALESCE(SUM(debit), 0) FROM financial.journal_entry_lines WHERE entry_id = je.id) as total_debit, + (SELECT COALESCE(SUM(credit), 0) FROM financial.journal_entry_lines WHERE entry_id = je.id) as total_credit + FROM financial.journal_entries je + LEFT JOIN auth.companies c ON je.company_id = c.id + LEFT JOIN financial.journals j ON je.journal_id = j.id + ${whereClause} + ORDER BY je.date DESC, je.name DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const entry = await queryOne( + `SELECT je.*, + c.name as company_name, + j.name as journal_name + FROM financial.journal_entries je + LEFT JOIN auth.companies c ON je.company_id = c.id + LEFT JOIN financial.journals j ON je.journal_id = j.id + WHERE je.id = $1 AND je.tenant_id = $2`, + [id, tenantId] + ); + + if (!entry) { + throw new NotFoundError('Póliza no encontrada'); + } + + // Get lines + const lines = await query( + `SELECT jel.*, + a.name as account_name, + a.code as account_code, + p.name as partner_name + FROM financial.journal_entry_lines jel + LEFT JOIN financial.accounts a ON jel.account_id = a.id + LEFT JOIN core.partners p ON jel.partner_id = p.id + WHERE jel.entry_id = $1 + ORDER BY jel.created_at`, + [id] + ); + + entry.lines = lines; + entry.total_debit = lines.reduce((sum, l) => sum + Number(l.debit), 0); + entry.total_credit = lines.reduce((sum, l) => sum + Number(l.credit), 0); + + return entry; + } + + async create(dto: CreateJournalEntryDto, tenantId: string, userId: string): Promise { + // Validate lines balance + const totalDebit = dto.lines.reduce((sum, l) => sum + l.debit, 0); + const totalCredit = dto.lines.reduce((sum, l) => sum + l.credit, 0); + + if (Math.abs(totalDebit - totalCredit) > 0.01) { + throw new ValidationError('La póliza no está balanceada. Débitos y créditos deben ser iguales.'); + } + + if (dto.lines.length < 2) { + throw new ValidationError('La póliza debe tener al menos 2 líneas.'); + } + + const client = await getClient(); + + try { + await client.query('BEGIN'); + + // Create entry + const entryResult = await client.query( + `INSERT INTO financial.journal_entries (tenant_id, company_id, journal_id, name, ref, date, notes, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [tenantId, dto.company_id, dto.journal_id, dto.name, dto.ref, dto.date, dto.notes, userId] + ); + const entry = entryResult.rows[0] as JournalEntry; + + // Create lines (include tenant_id for multi-tenant security) + for (const line of dto.lines) { + await client.query( + `INSERT INTO financial.journal_entry_lines (entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [entry.id, tenantId, line.account_id, line.partner_id, line.debit, line.credit, line.description, line.ref] + ); + } + + await client.query('COMMIT'); + + return this.findById(entry.id, tenantId); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async update(id: string, dto: UpdateJournalEntryDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'draft') { + throw new ConflictError('Solo se pueden modificar pólizas en estado borrador'); + } + + const client = await getClient(); + + try { + await client.query('BEGIN'); + + // Update entry header + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.ref !== undefined) { + updateFields.push(`ref = $${paramIndex++}`); + values.push(dto.ref); + } + if (dto.date !== undefined) { + updateFields.push(`date = $${paramIndex++}`); + values.push(dto.date); + } + if (dto.notes !== undefined) { + updateFields.push(`notes = $${paramIndex++}`); + values.push(dto.notes); + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id); + + if (updateFields.length > 2) { + await client.query( + `UPDATE financial.journal_entries SET ${updateFields.join(', ')} WHERE id = $${paramIndex}`, + values + ); + } + + // Update lines if provided + if (dto.lines) { + const totalDebit = dto.lines.reduce((sum, l) => sum + l.debit, 0); + const totalCredit = dto.lines.reduce((sum, l) => sum + l.credit, 0); + + if (Math.abs(totalDebit - totalCredit) > 0.01) { + throw new ValidationError('La póliza no está balanceada'); + } + + // Delete existing lines + await client.query(`DELETE FROM financial.journal_entry_lines WHERE entry_id = $1`, [id]); + + // Insert new lines (include tenant_id for multi-tenant security) + for (const line of dto.lines) { + await client.query( + `INSERT INTO financial.journal_entry_lines (entry_id, tenant_id, account_id, partner_id, debit, credit, description, ref) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [id, tenantId, line.account_id, line.partner_id, line.debit, line.credit, line.description, line.ref] + ); + } + } + + await client.query('COMMIT'); + + return this.findById(id, tenantId); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async post(id: string, tenantId: string, userId: string): Promise { + const entry = await this.findById(id, tenantId); + + if (entry.status !== 'draft') { + throw new ConflictError('Solo se pueden publicar pólizas en estado borrador'); + } + + // Validate balance + if (Math.abs((entry.total_debit || 0) - (entry.total_credit || 0)) > 0.01) { + throw new ValidationError('La póliza no está balanceada'); + } + + await query( + `UPDATE financial.journal_entries + SET status = 'posted', posted_at = CURRENT_TIMESTAMP, posted_by = $1, updated_at = CURRENT_TIMESTAMP, updated_by = $1 + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async cancel(id: string, tenantId: string, userId: string): Promise { + const entry = await this.findById(id, tenantId); + + if (entry.status === 'cancelled') { + throw new ConflictError('La póliza ya está cancelada'); + } + + await query( + `UPDATE financial.journal_entries + SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, cancelled_by = $1, updated_at = CURRENT_TIMESTAMP, updated_by = $1 + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + const entry = await this.findById(id, tenantId); + + if (entry.status !== 'draft') { + throw new ConflictError('Solo se pueden eliminar pólizas en estado borrador'); + } + + await query(`DELETE FROM financial.journal_entries WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } +} + +export const journalEntriesService = new JournalEntriesService(); diff --git a/src/modules/financial/journals.service.old.ts b/src/modules/financial/journals.service.old.ts new file mode 100644 index 0000000..8061b68 --- /dev/null +++ b/src/modules/financial/journals.service.old.ts @@ -0,0 +1,216 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ConflictError } from '../../shared/errors/index.js'; + +export type JournalType = 'sale' | 'purchase' | 'cash' | 'bank' | 'general'; + +export interface Journal { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + name: string; + code: string; + journal_type: JournalType; + default_account_id?: string; + default_account_name?: string; + sequence_id?: string; + currency_id?: string; + currency_code?: string; + active: boolean; + created_at: Date; +} + +export interface CreateJournalDto { + company_id: string; + name: string; + code: string; + journal_type: JournalType; + default_account_id?: string; + sequence_id?: string; + currency_id?: string; +} + +export interface UpdateJournalDto { + name?: string; + default_account_id?: string | null; + sequence_id?: string | null; + currency_id?: string | null; + active?: boolean; +} + +export interface JournalFilters { + company_id?: string; + journal_type?: JournalType; + active?: boolean; + page?: number; + limit?: number; +} + +class JournalsService { + async findAll(tenantId: string, filters: JournalFilters = {}): Promise<{ data: Journal[]; total: number }> { + const { company_id, journal_type, active, page = 1, limit = 50 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE j.tenant_id = $1 AND j.deleted_at IS NULL'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND j.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (journal_type) { + whereClause += ` AND j.journal_type = $${paramIndex++}`; + params.push(journal_type); + } + + if (active !== undefined) { + whereClause += ` AND j.active = $${paramIndex++}`; + params.push(active); + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM financial.journals j ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT j.*, + c.name as company_name, + a.name as default_account_name, + cur.code as currency_code + FROM financial.journals j + LEFT JOIN auth.companies c ON j.company_id = c.id + LEFT JOIN financial.accounts a ON j.default_account_id = a.id + LEFT JOIN core.currencies cur ON j.currency_id = cur.id + ${whereClause} + ORDER BY j.code + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const journal = await queryOne( + `SELECT j.*, + c.name as company_name, + a.name as default_account_name, + cur.code as currency_code + FROM financial.journals j + LEFT JOIN auth.companies c ON j.company_id = c.id + LEFT JOIN financial.accounts a ON j.default_account_id = a.id + LEFT JOIN core.currencies cur ON j.currency_id = cur.id + WHERE j.id = $1 AND j.tenant_id = $2 AND j.deleted_at IS NULL`, + [id, tenantId] + ); + + if (!journal) { + throw new NotFoundError('Diario no encontrado'); + } + + return journal; + } + + async create(dto: CreateJournalDto, tenantId: string, userId: string): Promise { + // Validate unique code within company + const existing = await queryOne( + `SELECT id FROM financial.journals WHERE company_id = $1 AND code = $2 AND deleted_at IS NULL`, + [dto.company_id, dto.code] + ); + if (existing) { + throw new ConflictError(`Ya existe un diario con código ${dto.code}`); + } + + const journal = await queryOne( + `INSERT INTO financial.journals (tenant_id, company_id, name, code, journal_type, default_account_id, sequence_id, currency_id, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + tenantId, + dto.company_id, + dto.name, + dto.code, + dto.journal_type, + dto.default_account_id, + dto.sequence_id, + dto.currency_id, + userId, + ] + ); + + return journal!; + } + + async update(id: string, dto: UpdateJournalDto, tenantId: string, userId: string): Promise { + await this.findById(id, tenantId); + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.name !== undefined) { + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.default_account_id !== undefined) { + updateFields.push(`default_account_id = $${paramIndex++}`); + values.push(dto.default_account_id); + } + if (dto.sequence_id !== undefined) { + updateFields.push(`sequence_id = $${paramIndex++}`); + values.push(dto.sequence_id); + } + if (dto.currency_id !== undefined) { + updateFields.push(`currency_id = $${paramIndex++}`); + values.push(dto.currency_id); + } + if (dto.active !== undefined) { + updateFields.push(`active = $${paramIndex++}`); + values.push(dto.active); + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + const journal = await queryOne( + `UPDATE financial.journals + SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} AND deleted_at IS NULL + RETURNING *`, + values + ); + + return journal!; + } + + async delete(id: string, tenantId: string, userId: string): Promise { + await this.findById(id, tenantId); + + // Check if journal has entries + const entries = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM financial.journal_entries WHERE journal_id = $1`, + [id] + ); + if (parseInt(entries?.count || '0', 10) > 0) { + throw new ConflictError('No se puede eliminar un diario que tiene pólizas'); + } + + // Soft delete + await query( + `UPDATE financial.journals SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $1 WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + } +} + +export const journalsService = new JournalsService(); diff --git a/src/modules/financial/journals.service.ts b/src/modules/financial/journals.service.ts new file mode 100644 index 0000000..8061b68 --- /dev/null +++ b/src/modules/financial/journals.service.ts @@ -0,0 +1,216 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ConflictError } from '../../shared/errors/index.js'; + +export type JournalType = 'sale' | 'purchase' | 'cash' | 'bank' | 'general'; + +export interface Journal { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + name: string; + code: string; + journal_type: JournalType; + default_account_id?: string; + default_account_name?: string; + sequence_id?: string; + currency_id?: string; + currency_code?: string; + active: boolean; + created_at: Date; +} + +export interface CreateJournalDto { + company_id: string; + name: string; + code: string; + journal_type: JournalType; + default_account_id?: string; + sequence_id?: string; + currency_id?: string; +} + +export interface UpdateJournalDto { + name?: string; + default_account_id?: string | null; + sequence_id?: string | null; + currency_id?: string | null; + active?: boolean; +} + +export interface JournalFilters { + company_id?: string; + journal_type?: JournalType; + active?: boolean; + page?: number; + limit?: number; +} + +class JournalsService { + async findAll(tenantId: string, filters: JournalFilters = {}): Promise<{ data: Journal[]; total: number }> { + const { company_id, journal_type, active, page = 1, limit = 50 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE j.tenant_id = $1 AND j.deleted_at IS NULL'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND j.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (journal_type) { + whereClause += ` AND j.journal_type = $${paramIndex++}`; + params.push(journal_type); + } + + if (active !== undefined) { + whereClause += ` AND j.active = $${paramIndex++}`; + params.push(active); + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM financial.journals j ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT j.*, + c.name as company_name, + a.name as default_account_name, + cur.code as currency_code + FROM financial.journals j + LEFT JOIN auth.companies c ON j.company_id = c.id + LEFT JOIN financial.accounts a ON j.default_account_id = a.id + LEFT JOIN core.currencies cur ON j.currency_id = cur.id + ${whereClause} + ORDER BY j.code + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const journal = await queryOne( + `SELECT j.*, + c.name as company_name, + a.name as default_account_name, + cur.code as currency_code + FROM financial.journals j + LEFT JOIN auth.companies c ON j.company_id = c.id + LEFT JOIN financial.accounts a ON j.default_account_id = a.id + LEFT JOIN core.currencies cur ON j.currency_id = cur.id + WHERE j.id = $1 AND j.tenant_id = $2 AND j.deleted_at IS NULL`, + [id, tenantId] + ); + + if (!journal) { + throw new NotFoundError('Diario no encontrado'); + } + + return journal; + } + + async create(dto: CreateJournalDto, tenantId: string, userId: string): Promise { + // Validate unique code within company + const existing = await queryOne( + `SELECT id FROM financial.journals WHERE company_id = $1 AND code = $2 AND deleted_at IS NULL`, + [dto.company_id, dto.code] + ); + if (existing) { + throw new ConflictError(`Ya existe un diario con código ${dto.code}`); + } + + const journal = await queryOne( + `INSERT INTO financial.journals (tenant_id, company_id, name, code, journal_type, default_account_id, sequence_id, currency_id, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + tenantId, + dto.company_id, + dto.name, + dto.code, + dto.journal_type, + dto.default_account_id, + dto.sequence_id, + dto.currency_id, + userId, + ] + ); + + return journal!; + } + + async update(id: string, dto: UpdateJournalDto, tenantId: string, userId: string): Promise { + await this.findById(id, tenantId); + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.name !== undefined) { + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.default_account_id !== undefined) { + updateFields.push(`default_account_id = $${paramIndex++}`); + values.push(dto.default_account_id); + } + if (dto.sequence_id !== undefined) { + updateFields.push(`sequence_id = $${paramIndex++}`); + values.push(dto.sequence_id); + } + if (dto.currency_id !== undefined) { + updateFields.push(`currency_id = $${paramIndex++}`); + values.push(dto.currency_id); + } + if (dto.active !== undefined) { + updateFields.push(`active = $${paramIndex++}`); + values.push(dto.active); + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + const journal = await queryOne( + `UPDATE financial.journals + SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} AND deleted_at IS NULL + RETURNING *`, + values + ); + + return journal!; + } + + async delete(id: string, tenantId: string, userId: string): Promise { + await this.findById(id, tenantId); + + // Check if journal has entries + const entries = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM financial.journal_entries WHERE journal_id = $1`, + [id] + ); + if (parseInt(entries?.count || '0', 10) > 0) { + throw new ConflictError('No se puede eliminar un diario que tiene pólizas'); + } + + // Soft delete + await query( + `UPDATE financial.journals SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $1 WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + } +} + +export const journalsService = new JournalsService(); diff --git a/src/modules/financial/payments.service.ts b/src/modules/financial/payments.service.ts new file mode 100644 index 0000000..531103c --- /dev/null +++ b/src/modules/financial/payments.service.ts @@ -0,0 +1,456 @@ +import { query, queryOne, getClient } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; + +export interface PaymentInvoice { + invoice_id: string; + invoice_number?: string; + amount: number; +} + +export interface Payment { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + partner_id: string; + partner_name?: string; + payment_type: 'inbound' | 'outbound'; + payment_method: 'cash' | 'bank_transfer' | 'check' | 'card' | 'other'; + amount: number; + currency_id: string; + currency_code?: string; + payment_date: Date; + ref?: string; + status: 'draft' | 'posted' | 'reconciled' | 'cancelled'; + journal_id: string; + journal_name?: string; + journal_entry_id?: string; + notes?: string; + invoices?: PaymentInvoice[]; + created_at: Date; + posted_at?: Date; +} + +export interface CreatePaymentDto { + company_id: string; + partner_id: string; + payment_type: 'inbound' | 'outbound'; + payment_method: 'cash' | 'bank_transfer' | 'check' | 'card' | 'other'; + amount: number; + currency_id: string; + payment_date?: string; + ref?: string; + journal_id: string; + notes?: string; +} + +export interface UpdatePaymentDto { + partner_id?: string; + payment_method?: 'cash' | 'bank_transfer' | 'check' | 'card' | 'other'; + amount?: number; + currency_id?: string; + payment_date?: string; + ref?: string | null; + journal_id?: string; + notes?: string | null; +} + +export interface ReconcileDto { + invoices: { invoice_id: string; amount: number }[]; +} + +export interface PaymentFilters { + company_id?: string; + partner_id?: string; + payment_type?: string; + payment_method?: string; + status?: string; + date_from?: string; + date_to?: string; + search?: string; + page?: number; + limit?: number; +} + +class PaymentsService { + async findAll(tenantId: string, filters: PaymentFilters = {}): Promise<{ data: Payment[]; total: number }> { + const { company_id, partner_id, payment_type, payment_method, status, date_from, date_to, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE p.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND p.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (partner_id) { + whereClause += ` AND p.partner_id = $${paramIndex++}`; + params.push(partner_id); + } + + if (payment_type) { + whereClause += ` AND p.payment_type = $${paramIndex++}`; + params.push(payment_type); + } + + if (payment_method) { + whereClause += ` AND p.payment_method = $${paramIndex++}`; + params.push(payment_method); + } + + if (status) { + whereClause += ` AND p.status = $${paramIndex++}`; + params.push(status); + } + + if (date_from) { + whereClause += ` AND p.payment_date >= $${paramIndex++}`; + params.push(date_from); + } + + if (date_to) { + whereClause += ` AND p.payment_date <= $${paramIndex++}`; + params.push(date_to); + } + + if (search) { + whereClause += ` AND (p.ref ILIKE $${paramIndex} OR pr.name ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count + FROM financial.payments p + LEFT JOIN core.partners pr ON p.partner_id = pr.id + ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT p.*, + c.name as company_name, + pr.name as partner_name, + cu.code as currency_code, + j.name as journal_name + FROM financial.payments p + LEFT JOIN auth.companies c ON p.company_id = c.id + LEFT JOIN core.partners pr ON p.partner_id = pr.id + LEFT JOIN core.currencies cu ON p.currency_id = cu.id + LEFT JOIN financial.journals j ON p.journal_id = j.id + ${whereClause} + ORDER BY p.payment_date DESC, p.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const payment = await queryOne( + `SELECT p.*, + c.name as company_name, + pr.name as partner_name, + cu.code as currency_code, + j.name as journal_name + FROM financial.payments p + LEFT JOIN auth.companies c ON p.company_id = c.id + LEFT JOIN core.partners pr ON p.partner_id = pr.id + LEFT JOIN core.currencies cu ON p.currency_id = cu.id + LEFT JOIN financial.journals j ON p.journal_id = j.id + WHERE p.id = $1 AND p.tenant_id = $2`, + [id, tenantId] + ); + + if (!payment) { + throw new NotFoundError('Pago no encontrado'); + } + + // Get reconciled invoices + const invoices = await query( + `SELECT pi.invoice_id, pi.amount, i.number as invoice_number + FROM financial.payment_invoice pi + LEFT JOIN financial.invoices i ON pi.invoice_id = i.id + WHERE pi.payment_id = $1`, + [id] + ); + + payment.invoices = invoices; + + return payment; + } + + async create(dto: CreatePaymentDto, tenantId: string, userId: string): Promise { + if (dto.amount <= 0) { + throw new ValidationError('El monto debe ser mayor a 0'); + } + + const paymentDate = dto.payment_date || new Date().toISOString().split('T')[0]; + + const payment = await queryOne( + `INSERT INTO financial.payments ( + tenant_id, company_id, partner_id, payment_type, payment_method, + amount, currency_id, payment_date, ref, journal_id, notes, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING *`, + [ + tenantId, dto.company_id, dto.partner_id, dto.payment_type, dto.payment_method, + dto.amount, dto.currency_id, paymentDate, dto.ref, dto.journal_id, dto.notes, userId + ] + ); + + return payment!; + } + + async update(id: string, dto: UpdatePaymentDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'draft') { + throw new ValidationError('Solo se pueden editar pagos en estado borrador'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.partner_id !== undefined) { + updateFields.push(`partner_id = $${paramIndex++}`); + values.push(dto.partner_id); + } + if (dto.payment_method !== undefined) { + updateFields.push(`payment_method = $${paramIndex++}`); + values.push(dto.payment_method); + } + if (dto.amount !== undefined) { + if (dto.amount <= 0) { + throw new ValidationError('El monto debe ser mayor a 0'); + } + updateFields.push(`amount = $${paramIndex++}`); + values.push(dto.amount); + } + if (dto.currency_id !== undefined) { + updateFields.push(`currency_id = $${paramIndex++}`); + values.push(dto.currency_id); + } + if (dto.payment_date !== undefined) { + updateFields.push(`payment_date = $${paramIndex++}`); + values.push(dto.payment_date); + } + if (dto.ref !== undefined) { + updateFields.push(`ref = $${paramIndex++}`); + values.push(dto.ref); + } + if (dto.journal_id !== undefined) { + updateFields.push(`journal_id = $${paramIndex++}`); + values.push(dto.journal_id); + } + if (dto.notes !== undefined) { + updateFields.push(`notes = $${paramIndex++}`); + values.push(dto.notes); + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + await query( + `UPDATE financial.payments SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'draft') { + throw new ValidationError('Solo se pueden eliminar pagos en estado borrador'); + } + + await query( + `DELETE FROM financial.payments WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + } + + async post(id: string, tenantId: string, userId: string): Promise { + const payment = await this.findById(id, tenantId); + + if (payment.status !== 'draft') { + throw new ValidationError('Solo se pueden publicar pagos en estado borrador'); + } + + await query( + `UPDATE financial.payments SET + status = 'posted', + posted_at = CURRENT_TIMESTAMP, + posted_by = $1, + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async reconcile(id: string, dto: ReconcileDto, tenantId: string, userId: string): Promise { + const payment = await this.findById(id, tenantId); + + if (payment.status === 'draft') { + throw new ValidationError('Debe publicar el pago antes de conciliar'); + } + + if (payment.status === 'cancelled') { + throw new ValidationError('No se puede conciliar un pago cancelado'); + } + + // Validate total amount matches + const totalReconciled = dto.invoices.reduce((sum, inv) => sum + inv.amount, 0); + if (totalReconciled > payment.amount) { + throw new ValidationError('El monto total conciliado excede el monto del pago'); + } + + const client = await getClient(); + try { + await client.query('BEGIN'); + + // Remove existing reconciliations + await client.query( + `DELETE FROM financial.payment_invoice WHERE payment_id = $1`, + [id] + ); + + // Add new reconciliations + for (const inv of dto.invoices) { + // Validate invoice exists and belongs to same partner + const invoice = await client.query( + `SELECT id, partner_id, amount_residual, status FROM financial.invoices + WHERE id = $1 AND tenant_id = $2`, + [inv.invoice_id, tenantId] + ); + + if (invoice.rows.length === 0) { + throw new ValidationError(`Factura ${inv.invoice_id} no encontrada`); + } + + if (invoice.rows[0].partner_id !== payment.partner_id) { + throw new ValidationError('La factura debe pertenecer al mismo cliente/proveedor'); + } + + if (invoice.rows[0].status !== 'open') { + throw new ValidationError('Solo se pueden conciliar facturas abiertas'); + } + + if (inv.amount > invoice.rows[0].amount_residual) { + throw new ValidationError(`El monto excede el saldo pendiente de la factura`); + } + + await client.query( + `INSERT INTO financial.payment_invoice (payment_id, invoice_id, amount) + VALUES ($1, $2, $3)`, + [id, inv.invoice_id, inv.amount] + ); + + // Update invoice amounts + await client.query( + `UPDATE financial.invoices SET + amount_paid = amount_paid + $1, + amount_residual = amount_residual - $1, + status = CASE WHEN amount_residual - $1 <= 0 THEN 'paid'::financial.invoice_status ELSE status END + WHERE id = $2`, + [inv.amount, inv.invoice_id] + ); + } + + // Update payment status + await client.query( + `UPDATE financial.payments SET + status = 'reconciled', + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2`, + [userId, id] + ); + + await client.query('COMMIT'); + + return this.findById(id, tenantId); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async cancel(id: string, tenantId: string, userId: string): Promise { + const payment = await this.findById(id, tenantId); + + if (payment.status === 'cancelled') { + throw new ValidationError('El pago ya está cancelado'); + } + + const client = await getClient(); + try { + await client.query('BEGIN'); + + // Reverse reconciliations if any + if (payment.invoices && payment.invoices.length > 0) { + for (const inv of payment.invoices) { + await client.query( + `UPDATE financial.invoices SET + amount_paid = amount_paid - $1, + amount_residual = amount_residual + $1, + status = 'open'::financial.invoice_status + WHERE id = $2`, + [inv.amount, inv.invoice_id] + ); + } + + await client.query( + `DELETE FROM financial.payment_invoice WHERE payment_id = $1`, + [id] + ); + } + + // Cancel payment + await client.query( + `UPDATE financial.payments SET + status = 'cancelled', + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2`, + [userId, id] + ); + + await client.query('COMMIT'); + + return this.findById(id, tenantId); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } +} + +export const paymentsService = new PaymentsService(); diff --git a/src/modules/financial/taxes.service.old.ts b/src/modules/financial/taxes.service.old.ts new file mode 100644 index 0000000..d856ca3 --- /dev/null +++ b/src/modules/financial/taxes.service.old.ts @@ -0,0 +1,382 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ConflictError } from '../../shared/errors/index.js'; + +export interface Tax { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + name: string; + code: string; + tax_type: 'sales' | 'purchase' | 'all'; + amount: number; + included_in_price: boolean; + active: boolean; + created_at: Date; +} + +export interface CreateTaxDto { + company_id: string; + name: string; + code: string; + tax_type: 'sales' | 'purchase' | 'all'; + amount: number; + included_in_price?: boolean; +} + +export interface UpdateTaxDto { + name?: string; + code?: string; + tax_type?: 'sales' | 'purchase' | 'all'; + amount?: number; + included_in_price?: boolean; + active?: boolean; +} + +export interface TaxFilters { + company_id?: string; + tax_type?: string; + active?: boolean; + search?: string; + page?: number; + limit?: number; +} + +class TaxesService { + async findAll(tenantId: string, filters: TaxFilters = {}): Promise<{ data: Tax[]; total: number }> { + const { company_id, tax_type, active, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE t.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND t.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (tax_type) { + whereClause += ` AND t.tax_type = $${paramIndex++}`; + params.push(tax_type); + } + + if (active !== undefined) { + whereClause += ` AND t.active = $${paramIndex++}`; + params.push(active); + } + + if (search) { + whereClause += ` AND (t.name ILIKE $${paramIndex} OR t.code ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM financial.taxes t ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT t.*, + c.name as company_name + FROM financial.taxes t + LEFT JOIN auth.companies c ON t.company_id = c.id + ${whereClause} + ORDER BY t.name + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const tax = await queryOne( + `SELECT t.*, + c.name as company_name + FROM financial.taxes t + LEFT JOIN auth.companies c ON t.company_id = c.id + WHERE t.id = $1 AND t.tenant_id = $2`, + [id, tenantId] + ); + + if (!tax) { + throw new NotFoundError('Impuesto no encontrado'); + } + + return tax; + } + + async create(dto: CreateTaxDto, tenantId: string, userId: string): Promise { + // Check unique code + const existing = await queryOne( + `SELECT id FROM financial.taxes WHERE tenant_id = $1 AND code = $2`, + [tenantId, dto.code] + ); + + if (existing) { + throw new ConflictError('Ya existe un impuesto con ese código'); + } + + const tax = await queryOne( + `INSERT INTO financial.taxes ( + tenant_id, company_id, name, code, tax_type, amount, included_in_price, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [ + tenantId, dto.company_id, dto.name, dto.code, dto.tax_type, + dto.amount, dto.included_in_price ?? false, userId + ] + ); + + return tax!; + } + + async update(id: string, dto: UpdateTaxDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.name !== undefined) { + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.code !== undefined) { + // Check unique code + const existingCode = await queryOne( + `SELECT id FROM financial.taxes WHERE tenant_id = $1 AND code = $2 AND id != $3`, + [tenantId, dto.code, id] + ); + if (existingCode) { + throw new ConflictError('Ya existe un impuesto con ese código'); + } + updateFields.push(`code = $${paramIndex++}`); + values.push(dto.code); + } + if (dto.tax_type !== undefined) { + updateFields.push(`tax_type = $${paramIndex++}`); + values.push(dto.tax_type); + } + if (dto.amount !== undefined) { + updateFields.push(`amount = $${paramIndex++}`); + values.push(dto.amount); + } + if (dto.included_in_price !== undefined) { + updateFields.push(`included_in_price = $${paramIndex++}`); + values.push(dto.included_in_price); + } + if (dto.active !== undefined) { + updateFields.push(`active = $${paramIndex++}`); + values.push(dto.active); + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + await query( + `UPDATE financial.taxes SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + await this.findById(id, tenantId); + + // Check if tax is used in any invoice lines + const usageCheck = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM financial.invoice_lines + WHERE $1 = ANY(tax_ids)`, + [id] + ); + + if (parseInt(usageCheck?.count || '0') > 0) { + throw new ConflictError('No se puede eliminar: el impuesto está siendo usado en facturas'); + } + + await query( + `DELETE FROM financial.taxes WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + } + + /** + * Calcula impuestos para una linea de documento + * Sigue la logica de Odoo para calculos de IVA + */ + async calculateTaxes( + lineData: TaxCalculationInput, + tenantId: string, + transactionType: 'sales' | 'purchase' = 'sales' + ): Promise { + // Validar inputs + if (lineData.quantity <= 0 || lineData.priceUnit < 0) { + return { + amountUntaxed: 0, + amountTax: 0, + amountTotal: 0, + taxBreakdown: [], + }; + } + + // Calcular subtotal antes de impuestos + const subtotal = lineData.quantity * lineData.priceUnit; + const discountAmount = subtotal * (lineData.discount || 0) / 100; + const amountUntaxed = subtotal - discountAmount; + + // Si no hay impuestos, retornar solo el monto sin impuestos + if (!lineData.taxIds || lineData.taxIds.length === 0) { + return { + amountUntaxed, + amountTax: 0, + amountTotal: amountUntaxed, + taxBreakdown: [], + }; + } + + // Obtener impuestos de la BD + const taxResults = await query( + `SELECT * FROM financial.taxes + WHERE id = ANY($1) AND tenant_id = $2 AND active = true + AND (tax_type = $3 OR tax_type = 'all')`, + [lineData.taxIds, tenantId, transactionType] + ); + + if (taxResults.length === 0) { + return { + amountUntaxed, + amountTax: 0, + amountTotal: amountUntaxed, + taxBreakdown: [], + }; + } + + // Calcular impuestos + const taxBreakdown: TaxBreakdownItem[] = []; + let totalTax = 0; + + for (const tax of taxResults) { + let taxBase = amountUntaxed; + let taxAmount: number; + + if (tax.included_in_price) { + // Precio incluye impuesto (IVA incluido) + // Base = Precio / (1 + tasa) + // Impuesto = Precio - Base + taxBase = amountUntaxed / (1 + tax.amount / 100); + taxAmount = amountUntaxed - taxBase; + } else { + // Precio sin impuesto (IVA añadido) + // Impuesto = Base * tasa + taxAmount = amountUntaxed * tax.amount / 100; + } + + taxBreakdown.push({ + taxId: tax.id, + taxName: tax.name, + taxCode: tax.code, + taxRate: tax.amount, + includedInPrice: tax.included_in_price, + base: Math.round(taxBase * 100) / 100, + taxAmount: Math.round(taxAmount * 100) / 100, + }); + + totalTax += taxAmount; + } + + // Redondear a 2 decimales + const finalAmountTax = Math.round(totalTax * 100) / 100; + const finalAmountUntaxed = Math.round(amountUntaxed * 100) / 100; + const finalAmountTotal = Math.round((amountUntaxed + finalAmountTax) * 100) / 100; + + return { + amountUntaxed: finalAmountUntaxed, + amountTax: finalAmountTax, + amountTotal: finalAmountTotal, + taxBreakdown, + }; + } + + /** + * Calcula impuestos para multiples lineas (ej: para totales de documento) + */ + async calculateDocumentTaxes( + lines: TaxCalculationInput[], + tenantId: string, + transactionType: 'sales' | 'purchase' = 'sales' + ): Promise { + let totalUntaxed = 0; + let totalTax = 0; + const allBreakdown: TaxBreakdownItem[] = []; + + for (const line of lines) { + const result = await this.calculateTaxes(line, tenantId, transactionType); + totalUntaxed += result.amountUntaxed; + totalTax += result.amountTax; + allBreakdown.push(...result.taxBreakdown); + } + + // Consolidar breakdown por impuesto + const consolidatedBreakdown = new Map(); + for (const item of allBreakdown) { + const existing = consolidatedBreakdown.get(item.taxId); + if (existing) { + existing.base += item.base; + existing.taxAmount += item.taxAmount; + } else { + consolidatedBreakdown.set(item.taxId, { ...item }); + } + } + + return { + amountUntaxed: Math.round(totalUntaxed * 100) / 100, + amountTax: Math.round(totalTax * 100) / 100, + amountTotal: Math.round((totalUntaxed + totalTax) * 100) / 100, + taxBreakdown: Array.from(consolidatedBreakdown.values()), + }; + } +} + +// Interfaces para calculo de impuestos +export interface TaxCalculationInput { + quantity: number; + priceUnit: number; + discount: number; + taxIds: string[]; +} + +export interface TaxBreakdownItem { + taxId: string; + taxName: string; + taxCode: string; + taxRate: number; + includedInPrice: boolean; + base: number; + taxAmount: number; +} + +export interface TaxCalculationResult { + amountUntaxed: number; + amountTax: number; + amountTotal: number; + taxBreakdown: TaxBreakdownItem[]; +} + +export const taxesService = new TaxesService(); diff --git a/src/modules/financial/taxes.service.ts b/src/modules/financial/taxes.service.ts new file mode 100644 index 0000000..d856ca3 --- /dev/null +++ b/src/modules/financial/taxes.service.ts @@ -0,0 +1,382 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ConflictError } from '../../shared/errors/index.js'; + +export interface Tax { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + name: string; + code: string; + tax_type: 'sales' | 'purchase' | 'all'; + amount: number; + included_in_price: boolean; + active: boolean; + created_at: Date; +} + +export interface CreateTaxDto { + company_id: string; + name: string; + code: string; + tax_type: 'sales' | 'purchase' | 'all'; + amount: number; + included_in_price?: boolean; +} + +export interface UpdateTaxDto { + name?: string; + code?: string; + tax_type?: 'sales' | 'purchase' | 'all'; + amount?: number; + included_in_price?: boolean; + active?: boolean; +} + +export interface TaxFilters { + company_id?: string; + tax_type?: string; + active?: boolean; + search?: string; + page?: number; + limit?: number; +} + +class TaxesService { + async findAll(tenantId: string, filters: TaxFilters = {}): Promise<{ data: Tax[]; total: number }> { + const { company_id, tax_type, active, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE t.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND t.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (tax_type) { + whereClause += ` AND t.tax_type = $${paramIndex++}`; + params.push(tax_type); + } + + if (active !== undefined) { + whereClause += ` AND t.active = $${paramIndex++}`; + params.push(active); + } + + if (search) { + whereClause += ` AND (t.name ILIKE $${paramIndex} OR t.code ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM financial.taxes t ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT t.*, + c.name as company_name + FROM financial.taxes t + LEFT JOIN auth.companies c ON t.company_id = c.id + ${whereClause} + ORDER BY t.name + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const tax = await queryOne( + `SELECT t.*, + c.name as company_name + FROM financial.taxes t + LEFT JOIN auth.companies c ON t.company_id = c.id + WHERE t.id = $1 AND t.tenant_id = $2`, + [id, tenantId] + ); + + if (!tax) { + throw new NotFoundError('Impuesto no encontrado'); + } + + return tax; + } + + async create(dto: CreateTaxDto, tenantId: string, userId: string): Promise { + // Check unique code + const existing = await queryOne( + `SELECT id FROM financial.taxes WHERE tenant_id = $1 AND code = $2`, + [tenantId, dto.code] + ); + + if (existing) { + throw new ConflictError('Ya existe un impuesto con ese código'); + } + + const tax = await queryOne( + `INSERT INTO financial.taxes ( + tenant_id, company_id, name, code, tax_type, amount, included_in_price, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [ + tenantId, dto.company_id, dto.name, dto.code, dto.tax_type, + dto.amount, dto.included_in_price ?? false, userId + ] + ); + + return tax!; + } + + async update(id: string, dto: UpdateTaxDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.name !== undefined) { + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.code !== undefined) { + // Check unique code + const existingCode = await queryOne( + `SELECT id FROM financial.taxes WHERE tenant_id = $1 AND code = $2 AND id != $3`, + [tenantId, dto.code, id] + ); + if (existingCode) { + throw new ConflictError('Ya existe un impuesto con ese código'); + } + updateFields.push(`code = $${paramIndex++}`); + values.push(dto.code); + } + if (dto.tax_type !== undefined) { + updateFields.push(`tax_type = $${paramIndex++}`); + values.push(dto.tax_type); + } + if (dto.amount !== undefined) { + updateFields.push(`amount = $${paramIndex++}`); + values.push(dto.amount); + } + if (dto.included_in_price !== undefined) { + updateFields.push(`included_in_price = $${paramIndex++}`); + values.push(dto.included_in_price); + } + if (dto.active !== undefined) { + updateFields.push(`active = $${paramIndex++}`); + values.push(dto.active); + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + await query( + `UPDATE financial.taxes SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + await this.findById(id, tenantId); + + // Check if tax is used in any invoice lines + const usageCheck = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM financial.invoice_lines + WHERE $1 = ANY(tax_ids)`, + [id] + ); + + if (parseInt(usageCheck?.count || '0') > 0) { + throw new ConflictError('No se puede eliminar: el impuesto está siendo usado en facturas'); + } + + await query( + `DELETE FROM financial.taxes WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + } + + /** + * Calcula impuestos para una linea de documento + * Sigue la logica de Odoo para calculos de IVA + */ + async calculateTaxes( + lineData: TaxCalculationInput, + tenantId: string, + transactionType: 'sales' | 'purchase' = 'sales' + ): Promise { + // Validar inputs + if (lineData.quantity <= 0 || lineData.priceUnit < 0) { + return { + amountUntaxed: 0, + amountTax: 0, + amountTotal: 0, + taxBreakdown: [], + }; + } + + // Calcular subtotal antes de impuestos + const subtotal = lineData.quantity * lineData.priceUnit; + const discountAmount = subtotal * (lineData.discount || 0) / 100; + const amountUntaxed = subtotal - discountAmount; + + // Si no hay impuestos, retornar solo el monto sin impuestos + if (!lineData.taxIds || lineData.taxIds.length === 0) { + return { + amountUntaxed, + amountTax: 0, + amountTotal: amountUntaxed, + taxBreakdown: [], + }; + } + + // Obtener impuestos de la BD + const taxResults = await query( + `SELECT * FROM financial.taxes + WHERE id = ANY($1) AND tenant_id = $2 AND active = true + AND (tax_type = $3 OR tax_type = 'all')`, + [lineData.taxIds, tenantId, transactionType] + ); + + if (taxResults.length === 0) { + return { + amountUntaxed, + amountTax: 0, + amountTotal: amountUntaxed, + taxBreakdown: [], + }; + } + + // Calcular impuestos + const taxBreakdown: TaxBreakdownItem[] = []; + let totalTax = 0; + + for (const tax of taxResults) { + let taxBase = amountUntaxed; + let taxAmount: number; + + if (tax.included_in_price) { + // Precio incluye impuesto (IVA incluido) + // Base = Precio / (1 + tasa) + // Impuesto = Precio - Base + taxBase = amountUntaxed / (1 + tax.amount / 100); + taxAmount = amountUntaxed - taxBase; + } else { + // Precio sin impuesto (IVA añadido) + // Impuesto = Base * tasa + taxAmount = amountUntaxed * tax.amount / 100; + } + + taxBreakdown.push({ + taxId: tax.id, + taxName: tax.name, + taxCode: tax.code, + taxRate: tax.amount, + includedInPrice: tax.included_in_price, + base: Math.round(taxBase * 100) / 100, + taxAmount: Math.round(taxAmount * 100) / 100, + }); + + totalTax += taxAmount; + } + + // Redondear a 2 decimales + const finalAmountTax = Math.round(totalTax * 100) / 100; + const finalAmountUntaxed = Math.round(amountUntaxed * 100) / 100; + const finalAmountTotal = Math.round((amountUntaxed + finalAmountTax) * 100) / 100; + + return { + amountUntaxed: finalAmountUntaxed, + amountTax: finalAmountTax, + amountTotal: finalAmountTotal, + taxBreakdown, + }; + } + + /** + * Calcula impuestos para multiples lineas (ej: para totales de documento) + */ + async calculateDocumentTaxes( + lines: TaxCalculationInput[], + tenantId: string, + transactionType: 'sales' | 'purchase' = 'sales' + ): Promise { + let totalUntaxed = 0; + let totalTax = 0; + const allBreakdown: TaxBreakdownItem[] = []; + + for (const line of lines) { + const result = await this.calculateTaxes(line, tenantId, transactionType); + totalUntaxed += result.amountUntaxed; + totalTax += result.amountTax; + allBreakdown.push(...result.taxBreakdown); + } + + // Consolidar breakdown por impuesto + const consolidatedBreakdown = new Map(); + for (const item of allBreakdown) { + const existing = consolidatedBreakdown.get(item.taxId); + if (existing) { + existing.base += item.base; + existing.taxAmount += item.taxAmount; + } else { + consolidatedBreakdown.set(item.taxId, { ...item }); + } + } + + return { + amountUntaxed: Math.round(totalUntaxed * 100) / 100, + amountTax: Math.round(totalTax * 100) / 100, + amountTotal: Math.round((totalUntaxed + totalTax) * 100) / 100, + taxBreakdown: Array.from(consolidatedBreakdown.values()), + }; + } +} + +// Interfaces para calculo de impuestos +export interface TaxCalculationInput { + quantity: number; + priceUnit: number; + discount: number; + taxIds: string[]; +} + +export interface TaxBreakdownItem { + taxId: string; + taxName: string; + taxCode: string; + taxRate: number; + includedInPrice: boolean; + base: number; + taxAmount: number; +} + +export interface TaxCalculationResult { + amountUntaxed: number; + amountTax: number; + amountTotal: number; + taxBreakdown: TaxBreakdownItem[]; +} + +export const taxesService = new TaxesService(); diff --git a/src/modules/hr/contracts.service.ts b/src/modules/hr/contracts.service.ts new file mode 100644 index 0000000..1ea40b5 --- /dev/null +++ b/src/modules/hr/contracts.service.ts @@ -0,0 +1,346 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; + +export type ContractStatus = 'draft' | 'active' | 'expired' | 'terminated' | 'cancelled'; +export type ContractType = 'permanent' | 'temporary' | 'contractor' | 'internship' | 'part_time'; + +export interface Contract { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + employee_id: string; + employee_name?: string; + employee_number?: string; + name: string; + reference?: string; + contract_type: ContractType; + status: ContractStatus; + job_position_id?: string; + job_position_name?: string; + department_id?: string; + department_name?: string; + date_start: Date; + date_end?: Date; + trial_date_end?: Date; + wage: number; + wage_type: string; + currency_id?: string; + currency_code?: string; + hours_per_week: number; + vacation_days: number; + christmas_bonus_days: number; + document_url?: string; + notes?: string; + created_at: Date; +} + +export interface CreateContractDto { + company_id: string; + employee_id: string; + name: string; + reference?: string; + contract_type: ContractType; + job_position_id?: string; + department_id?: string; + date_start: string; + date_end?: string; + trial_date_end?: string; + wage: number; + wage_type?: string; + currency_id?: string; + hours_per_week?: number; + vacation_days?: number; + christmas_bonus_days?: number; + document_url?: string; + notes?: string; +} + +export interface UpdateContractDto { + reference?: string | null; + job_position_id?: string | null; + department_id?: string | null; + date_end?: string | null; + trial_date_end?: string | null; + wage?: number; + wage_type?: string; + currency_id?: string | null; + hours_per_week?: number; + vacation_days?: number; + christmas_bonus_days?: number; + document_url?: string | null; + notes?: string | null; +} + +export interface ContractFilters { + company_id?: string; + employee_id?: string; + status?: ContractStatus; + contract_type?: ContractType; + search?: string; + page?: number; + limit?: number; +} + +class ContractsService { + async findAll(tenantId: string, filters: ContractFilters = {}): Promise<{ data: Contract[]; total: number }> { + const { company_id, employee_id, status, contract_type, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE c.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND c.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (employee_id) { + whereClause += ` AND c.employee_id = $${paramIndex++}`; + params.push(employee_id); + } + + if (status) { + whereClause += ` AND c.status = $${paramIndex++}`; + params.push(status); + } + + if (contract_type) { + whereClause += ` AND c.contract_type = $${paramIndex++}`; + params.push(contract_type); + } + + if (search) { + whereClause += ` AND (c.name ILIKE $${paramIndex} OR c.reference ILIKE $${paramIndex} OR e.first_name ILIKE $${paramIndex} OR e.last_name ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count + FROM hr.contracts c + LEFT JOIN hr.employees e ON c.employee_id = e.id + ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT c.*, + co.name as company_name, + CONCAT(e.first_name, ' ', e.last_name) as employee_name, + e.employee_number, + j.name as job_position_name, + d.name as department_name, + cu.code as currency_code + FROM hr.contracts c + LEFT JOIN auth.companies co ON c.company_id = co.id + LEFT JOIN hr.employees e ON c.employee_id = e.id + LEFT JOIN hr.job_positions j ON c.job_position_id = j.id + LEFT JOIN hr.departments d ON c.department_id = d.id + LEFT JOIN core.currencies cu ON c.currency_id = cu.id + ${whereClause} + ORDER BY c.date_start DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const contract = await queryOne( + `SELECT c.*, + co.name as company_name, + CONCAT(e.first_name, ' ', e.last_name) as employee_name, + e.employee_number, + j.name as job_position_name, + d.name as department_name, + cu.code as currency_code + FROM hr.contracts c + LEFT JOIN auth.companies co ON c.company_id = co.id + LEFT JOIN hr.employees e ON c.employee_id = e.id + LEFT JOIN hr.job_positions j ON c.job_position_id = j.id + LEFT JOIN hr.departments d ON c.department_id = d.id + LEFT JOIN core.currencies cu ON c.currency_id = cu.id + WHERE c.id = $1 AND c.tenant_id = $2`, + [id, tenantId] + ); + + if (!contract) { + throw new NotFoundError('Contrato no encontrado'); + } + + return contract; + } + + async create(dto: CreateContractDto, tenantId: string, userId: string): Promise { + // Check if employee has an active contract + const activeContract = await queryOne( + `SELECT id FROM hr.contracts WHERE employee_id = $1 AND status = 'active'`, + [dto.employee_id] + ); + + if (activeContract) { + throw new ValidationError('El empleado ya tiene un contrato activo'); + } + + const contract = await queryOne( + `INSERT INTO hr.contracts ( + tenant_id, company_id, employee_id, name, reference, contract_type, + job_position_id, department_id, date_start, date_end, trial_date_end, + wage, wage_type, currency_id, hours_per_week, vacation_days, christmas_bonus_days, + document_url, notes, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) + RETURNING *`, + [ + tenantId, dto.company_id, dto.employee_id, dto.name, dto.reference, dto.contract_type, + dto.job_position_id, dto.department_id, dto.date_start, dto.date_end, dto.trial_date_end, + dto.wage, dto.wage_type || 'monthly', dto.currency_id, dto.hours_per_week || 40, + dto.vacation_days || 6, dto.christmas_bonus_days || 15, dto.document_url, dto.notes, userId + ] + ); + + return this.findById(contract!.id, tenantId); + } + + async update(id: string, dto: UpdateContractDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'draft') { + throw new ValidationError('Solo se pueden editar contratos en estado borrador'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + const fieldsToUpdate = [ + 'reference', 'job_position_id', 'department_id', 'date_end', 'trial_date_end', + 'wage', 'wage_type', 'currency_id', 'hours_per_week', 'vacation_days', + 'christmas_bonus_days', 'document_url', 'notes' + ]; + + for (const field of fieldsToUpdate) { + if ((dto as any)[field] !== undefined) { + updateFields.push(`${field} = $${paramIndex++}`); + values.push((dto as any)[field]); + } + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + + values.push(id, tenantId); + + await query( + `UPDATE hr.contracts SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async activate(id: string, tenantId: string, userId: string): Promise { + const contract = await this.findById(id, tenantId); + + if (contract.status !== 'draft') { + throw new ValidationError('Solo se pueden activar contratos en estado borrador'); + } + + // Check if employee has another active contract + const activeContract = await queryOne( + `SELECT id FROM hr.contracts WHERE employee_id = $1 AND status = 'active' AND id != $2`, + [contract.employee_id, id] + ); + + if (activeContract) { + throw new ValidationError('El empleado ya tiene otro contrato activo'); + } + + await query( + `UPDATE hr.contracts SET + status = 'active', + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + // Update employee department and position if specified + if (contract.department_id || contract.job_position_id) { + await query( + `UPDATE hr.employees SET + department_id = COALESCE($1, department_id), + job_position_id = COALESCE($2, job_position_id), + updated_by = $3, + updated_at = CURRENT_TIMESTAMP + WHERE id = $4`, + [contract.department_id, contract.job_position_id, userId, contract.employee_id] + ); + } + + return this.findById(id, tenantId); + } + + async terminate(id: string, terminationDate: string, tenantId: string, userId: string): Promise { + const contract = await this.findById(id, tenantId); + + if (contract.status !== 'active') { + throw new ValidationError('Solo se pueden terminar contratos activos'); + } + + await query( + `UPDATE hr.contracts SET + status = 'terminated', + date_end = $1, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $3 AND tenant_id = $4`, + [terminationDate, userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async cancel(id: string, tenantId: string, userId: string): Promise { + const contract = await this.findById(id, tenantId); + + if (contract.status === 'active' || contract.status === 'terminated') { + throw new ValidationError('No se puede cancelar un contrato activo o terminado'); + } + + await query( + `UPDATE hr.contracts SET + status = 'cancelled', + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + const contract = await this.findById(id, tenantId); + + if (contract.status !== 'draft' && contract.status !== 'cancelled') { + throw new ValidationError('Solo se pueden eliminar contratos en borrador o cancelados'); + } + + await query(`DELETE FROM hr.contracts WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } +} + +export const contractsService = new ContractsService(); diff --git a/src/modules/hr/departments.service.ts b/src/modules/hr/departments.service.ts new file mode 100644 index 0000000..5d676e8 --- /dev/null +++ b/src/modules/hr/departments.service.ts @@ -0,0 +1,393 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ConflictError } from '../../shared/errors/index.js'; + +export interface Department { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + name: string; + code?: string; + parent_id?: string; + parent_name?: string; + manager_id?: string; + manager_name?: string; + description?: string; + color?: string; + active: boolean; + employee_count?: number; + created_at: Date; +} + +export interface CreateDepartmentDto { + company_id: string; + name: string; + code?: string; + parent_id?: string; + manager_id?: string; + description?: string; + color?: string; +} + +export interface UpdateDepartmentDto { + name?: string; + code?: string | null; + parent_id?: string | null; + manager_id?: string | null; + description?: string | null; + color?: string | null; + active?: boolean; +} + +export interface DepartmentFilters { + company_id?: string; + active?: boolean; + search?: string; + page?: number; + limit?: number; +} + +// Job Position interfaces +export interface JobPosition { + id: string; + tenant_id: string; + name: string; + department_id?: string; + department_name?: string; + description?: string; + requirements?: string; + responsibilities?: string; + min_salary?: number; + max_salary?: number; + active: boolean; + employee_count?: number; + created_at: Date; +} + +export interface CreateJobPositionDto { + name: string; + department_id?: string; + description?: string; + requirements?: string; + responsibilities?: string; + min_salary?: number; + max_salary?: number; +} + +export interface UpdateJobPositionDto { + name?: string; + department_id?: string | null; + description?: string | null; + requirements?: string | null; + responsibilities?: string | null; + min_salary?: number | null; + max_salary?: number | null; + active?: boolean; +} + +class DepartmentsService { + // ========== DEPARTMENTS ========== + + async findAll(tenantId: string, filters: DepartmentFilters = {}): Promise<{ data: Department[]; total: number }> { + const { company_id, active, search, page = 1, limit = 50 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE d.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND d.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (active !== undefined) { + whereClause += ` AND d.active = $${paramIndex++}`; + params.push(active); + } + + if (search) { + whereClause += ` AND (d.name ILIKE $${paramIndex} OR d.code ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM hr.departments d ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT d.*, + c.name as company_name, + p.name as parent_name, + CONCAT(m.first_name, ' ', m.last_name) as manager_name, + COALESCE(ec.employee_count, 0) as employee_count + FROM hr.departments d + LEFT JOIN auth.companies c ON d.company_id = c.id + LEFT JOIN hr.departments p ON d.parent_id = p.id + LEFT JOIN hr.employees m ON d.manager_id = m.id + LEFT JOIN ( + SELECT department_id, COUNT(*) as employee_count + FROM hr.employees + WHERE status = 'active' + GROUP BY department_id + ) ec ON d.id = ec.department_id + ${whereClause} + ORDER BY d.name + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const department = await queryOne( + `SELECT d.*, + c.name as company_name, + p.name as parent_name, + CONCAT(m.first_name, ' ', m.last_name) as manager_name, + COALESCE(ec.employee_count, 0) as employee_count + FROM hr.departments d + LEFT JOIN auth.companies c ON d.company_id = c.id + LEFT JOIN hr.departments p ON d.parent_id = p.id + LEFT JOIN hr.employees m ON d.manager_id = m.id + LEFT JOIN ( + SELECT department_id, COUNT(*) as employee_count + FROM hr.employees + WHERE status = 'active' + GROUP BY department_id + ) ec ON d.id = ec.department_id + WHERE d.id = $1 AND d.tenant_id = $2`, + [id, tenantId] + ); + + if (!department) { + throw new NotFoundError('Departamento no encontrado'); + } + + return department; + } + + async create(dto: CreateDepartmentDto, tenantId: string, userId: string): Promise { + // Check unique name within company + const existing = await queryOne( + `SELECT id FROM hr.departments WHERE name = $1 AND company_id = $2 AND tenant_id = $3`, + [dto.name, dto.company_id, tenantId] + ); + + if (existing) { + throw new ConflictError('Ya existe un departamento con ese nombre en esta empresa'); + } + + const department = await queryOne( + `INSERT INTO hr.departments (tenant_id, company_id, name, code, parent_id, manager_id, description, color, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [tenantId, dto.company_id, dto.name, dto.code, dto.parent_id, dto.manager_id, dto.description, dto.color, userId] + ); + + return this.findById(department!.id, tenantId); + } + + async update(id: string, dto: UpdateDepartmentDto, tenantId: string): Promise { + const existing = await this.findById(id, tenantId); + + // Check unique name if changing + if (dto.name && dto.name !== existing.name) { + const nameExists = await queryOne( + `SELECT id FROM hr.departments WHERE name = $1 AND company_id = $2 AND tenant_id = $3 AND id != $4`, + [dto.name, existing.company_id, tenantId, id] + ); + if (nameExists) { + throw new ConflictError('Ya existe un departamento con ese nombre en esta empresa'); + } + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + const fieldsToUpdate = ['name', 'code', 'parent_id', 'manager_id', 'description', 'color', 'active']; + + for (const field of fieldsToUpdate) { + if ((dto as any)[field] !== undefined) { + updateFields.push(`${field} = $${paramIndex++}`); + values.push((dto as any)[field]); + } + } + + if (updateFields.length === 0) { + return existing; + } + + values.push(id, tenantId); + + await query( + `UPDATE hr.departments SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + await this.findById(id, tenantId); + + // Check if department has employees + const hasEmployees = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM hr.employees WHERE department_id = $1`, + [id] + ); + + if (parseInt(hasEmployees?.count || '0') > 0) { + throw new ConflictError('No se puede eliminar un departamento con empleados asociados'); + } + + // Check if department has children + const hasChildren = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM hr.departments WHERE parent_id = $1`, + [id] + ); + + if (parseInt(hasChildren?.count || '0') > 0) { + throw new ConflictError('No se puede eliminar un departamento con subdepartamentos'); + } + + await query(`DELETE FROM hr.departments WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } + + // ========== JOB POSITIONS ========== + + async getJobPositions(tenantId: string, includeInactive = false): Promise { + let whereClause = 'WHERE j.tenant_id = $1'; + if (!includeInactive) { + whereClause += ' AND j.active = TRUE'; + } + + return query( + `SELECT j.*, + d.name as department_name, + COALESCE(ec.employee_count, 0) as employee_count + FROM hr.job_positions j + LEFT JOIN hr.departments d ON j.department_id = d.id + LEFT JOIN ( + SELECT job_position_id, COUNT(*) as employee_count + FROM hr.employees + WHERE status = 'active' + GROUP BY job_position_id + ) ec ON j.id = ec.job_position_id + ${whereClause} + ORDER BY j.name`, + [tenantId] + ); + } + + async getJobPositionById(id: string, tenantId: string): Promise { + const position = await queryOne( + `SELECT j.*, + d.name as department_name, + COALESCE(ec.employee_count, 0) as employee_count + FROM hr.job_positions j + LEFT JOIN hr.departments d ON j.department_id = d.id + LEFT JOIN ( + SELECT job_position_id, COUNT(*) as employee_count + FROM hr.employees + WHERE status = 'active' + GROUP BY job_position_id + ) ec ON j.id = ec.job_position_id + WHERE j.id = $1 AND j.tenant_id = $2`, + [id, tenantId] + ); + + if (!position) { + throw new NotFoundError('Puesto no encontrado'); + } + + return position; + } + + async createJobPosition(dto: CreateJobPositionDto, tenantId: string): Promise { + const existing = await queryOne( + `SELECT id FROM hr.job_positions WHERE name = $1 AND tenant_id = $2`, + [dto.name, tenantId] + ); + + if (existing) { + throw new ConflictError('Ya existe un puesto con ese nombre'); + } + + const position = await queryOne( + `INSERT INTO hr.job_positions (tenant_id, name, department_id, description, requirements, responsibilities, min_salary, max_salary) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [tenantId, dto.name, dto.department_id, dto.description, dto.requirements, dto.responsibilities, dto.min_salary, dto.max_salary] + ); + + return position!; + } + + async updateJobPosition(id: string, dto: UpdateJobPositionDto, tenantId: string): Promise { + const existing = await this.getJobPositionById(id, tenantId); + + if (dto.name && dto.name !== existing.name) { + const nameExists = await queryOne( + `SELECT id FROM hr.job_positions WHERE name = $1 AND tenant_id = $2 AND id != $3`, + [dto.name, tenantId, id] + ); + if (nameExists) { + throw new ConflictError('Ya existe un puesto con ese nombre'); + } + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + const fieldsToUpdate = ['name', 'department_id', 'description', 'requirements', 'responsibilities', 'min_salary', 'max_salary', 'active']; + + for (const field of fieldsToUpdate) { + if ((dto as any)[field] !== undefined) { + updateFields.push(`${field} = $${paramIndex++}`); + values.push((dto as any)[field]); + } + } + + if (updateFields.length === 0) { + return existing; + } + + values.push(id, tenantId); + + await query( + `UPDATE hr.job_positions SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.getJobPositionById(id, tenantId); + } + + async deleteJobPosition(id: string, tenantId: string): Promise { + await this.getJobPositionById(id, tenantId); + + const hasEmployees = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM hr.employees WHERE job_position_id = $1`, + [id] + ); + + if (parseInt(hasEmployees?.count || '0') > 0) { + throw new ConflictError('No se puede eliminar un puesto con empleados asociados'); + } + + await query(`DELETE FROM hr.job_positions WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } +} + +export const departmentsService = new DepartmentsService(); diff --git a/src/modules/hr/employees.service.ts b/src/modules/hr/employees.service.ts new file mode 100644 index 0000000..7138b94 --- /dev/null +++ b/src/modules/hr/employees.service.ts @@ -0,0 +1,402 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js'; + +export type EmployeeStatus = 'active' | 'inactive' | 'on_leave' | 'terminated'; + +export interface Employee { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + employee_number: string; + first_name: string; + last_name: string; + middle_name?: string; + full_name?: string; + user_id?: string; + birth_date?: Date; + gender?: string; + marital_status?: string; + nationality?: string; + identification_id?: string; + identification_type?: string; + social_security_number?: string; + tax_id?: string; + email?: string; + work_email?: string; + phone?: string; + work_phone?: string; + mobile?: string; + emergency_contact?: string; + emergency_phone?: string; + street?: string; + city?: string; + state?: string; + zip?: string; + country?: string; + department_id?: string; + department_name?: string; + job_position_id?: string; + job_position_name?: string; + manager_id?: string; + manager_name?: string; + hire_date: Date; + termination_date?: Date; + status: EmployeeStatus; + bank_name?: string; + bank_account?: string; + bank_clabe?: string; + photo_url?: string; + notes?: string; + created_at: Date; +} + +export interface CreateEmployeeDto { + company_id: string; + employee_number: string; + first_name: string; + last_name: string; + middle_name?: string; + user_id?: string; + birth_date?: string; + gender?: string; + marital_status?: string; + nationality?: string; + identification_id?: string; + identification_type?: string; + social_security_number?: string; + tax_id?: string; + email?: string; + work_email?: string; + phone?: string; + work_phone?: string; + mobile?: string; + emergency_contact?: string; + emergency_phone?: string; + street?: string; + city?: string; + state?: string; + zip?: string; + country?: string; + department_id?: string; + job_position_id?: string; + manager_id?: string; + hire_date: string; + bank_name?: string; + bank_account?: string; + bank_clabe?: string; + photo_url?: string; + notes?: string; +} + +export interface UpdateEmployeeDto { + first_name?: string; + last_name?: string; + middle_name?: string | null; + user_id?: string | null; + birth_date?: string | null; + gender?: string | null; + marital_status?: string | null; + nationality?: string | null; + identification_id?: string | null; + identification_type?: string | null; + social_security_number?: string | null; + tax_id?: string | null; + email?: string | null; + work_email?: string | null; + phone?: string | null; + work_phone?: string | null; + mobile?: string | null; + emergency_contact?: string | null; + emergency_phone?: string | null; + street?: string | null; + city?: string | null; + state?: string | null; + zip?: string | null; + country?: string | null; + department_id?: string | null; + job_position_id?: string | null; + manager_id?: string | null; + bank_name?: string | null; + bank_account?: string | null; + bank_clabe?: string | null; + photo_url?: string | null; + notes?: string | null; +} + +export interface EmployeeFilters { + company_id?: string; + department_id?: string; + status?: EmployeeStatus; + manager_id?: string; + search?: string; + page?: number; + limit?: number; +} + +class EmployeesService { + async findAll(tenantId: string, filters: EmployeeFilters = {}): Promise<{ data: Employee[]; total: number }> { + const { company_id, department_id, status, manager_id, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE e.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND e.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (department_id) { + whereClause += ` AND e.department_id = $${paramIndex++}`; + params.push(department_id); + } + + if (status) { + whereClause += ` AND e.status = $${paramIndex++}`; + params.push(status); + } + + if (manager_id) { + whereClause += ` AND e.manager_id = $${paramIndex++}`; + params.push(manager_id); + } + + if (search) { + whereClause += ` AND (e.first_name ILIKE $${paramIndex} OR e.last_name ILIKE $${paramIndex} OR e.employee_number ILIKE $${paramIndex} OR e.email ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM hr.employees e ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT e.*, + CONCAT(e.first_name, ' ', COALESCE(e.middle_name || ' ', ''), e.last_name) as full_name, + c.name as company_name, + d.name as department_name, + j.name as job_position_name, + CONCAT(m.first_name, ' ', m.last_name) as manager_name + FROM hr.employees e + LEFT JOIN auth.companies c ON e.company_id = c.id + LEFT JOIN hr.departments d ON e.department_id = d.id + LEFT JOIN hr.job_positions j ON e.job_position_id = j.id + LEFT JOIN hr.employees m ON e.manager_id = m.id + ${whereClause} + ORDER BY e.last_name, e.first_name + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const employee = await queryOne( + `SELECT e.*, + CONCAT(e.first_name, ' ', COALESCE(e.middle_name || ' ', ''), e.last_name) as full_name, + c.name as company_name, + d.name as department_name, + j.name as job_position_name, + CONCAT(m.first_name, ' ', m.last_name) as manager_name + FROM hr.employees e + LEFT JOIN auth.companies c ON e.company_id = c.id + LEFT JOIN hr.departments d ON e.department_id = d.id + LEFT JOIN hr.job_positions j ON e.job_position_id = j.id + LEFT JOIN hr.employees m ON e.manager_id = m.id + WHERE e.id = $1 AND e.tenant_id = $2`, + [id, tenantId] + ); + + if (!employee) { + throw new NotFoundError('Empleado no encontrado'); + } + + return employee; + } + + async create(dto: CreateEmployeeDto, tenantId: string, userId: string): Promise { + // Check unique employee number + const existing = await queryOne( + `SELECT id FROM hr.employees WHERE employee_number = $1 AND tenant_id = $2`, + [dto.employee_number, tenantId] + ); + + if (existing) { + throw new ConflictError('Ya existe un empleado con ese numero'); + } + + const employee = await queryOne( + `INSERT INTO hr.employees ( + tenant_id, company_id, employee_number, first_name, last_name, middle_name, + user_id, birth_date, gender, marital_status, nationality, identification_id, + identification_type, social_security_number, tax_id, email, work_email, + phone, work_phone, mobile, emergency_contact, emergency_phone, street, city, + state, zip, country, department_id, job_position_id, manager_id, hire_date, + bank_name, bank_account, bank_clabe, photo_url, notes, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, + $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, + $31, $32, $33, $34, $35, $36, $37) + RETURNING *`, + [ + tenantId, dto.company_id, dto.employee_number, dto.first_name, dto.last_name, + dto.middle_name, dto.user_id, dto.birth_date, dto.gender, dto.marital_status, + dto.nationality, dto.identification_id, dto.identification_type, + dto.social_security_number, dto.tax_id, dto.email, dto.work_email, dto.phone, + dto.work_phone, dto.mobile, dto.emergency_contact, dto.emergency_phone, + dto.street, dto.city, dto.state, dto.zip, dto.country, dto.department_id, + dto.job_position_id, dto.manager_id, dto.hire_date, dto.bank_name, + dto.bank_account, dto.bank_clabe, dto.photo_url, dto.notes, userId + ] + ); + + return this.findById(employee!.id, tenantId); + } + + async update(id: string, dto: UpdateEmployeeDto, tenantId: string, userId: string): Promise { + await this.findById(id, tenantId); + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + const fieldsToUpdate = [ + 'first_name', 'last_name', 'middle_name', 'user_id', 'birth_date', 'gender', + 'marital_status', 'nationality', 'identification_id', 'identification_type', + 'social_security_number', 'tax_id', 'email', 'work_email', 'phone', 'work_phone', + 'mobile', 'emergency_contact', 'emergency_phone', 'street', 'city', 'state', + 'zip', 'country', 'department_id', 'job_position_id', 'manager_id', + 'bank_name', 'bank_account', 'bank_clabe', 'photo_url', 'notes' + ]; + + for (const field of fieldsToUpdate) { + if ((dto as any)[field] !== undefined) { + updateFields.push(`${field} = $${paramIndex++}`); + values.push((dto as any)[field]); + } + } + + if (updateFields.length === 0) { + return this.findById(id, tenantId); + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + + values.push(id, tenantId); + + await query( + `UPDATE hr.employees SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async terminate(id: string, terminationDate: string, tenantId: string, userId: string): Promise { + const employee = await this.findById(id, tenantId); + + if (employee.status === 'terminated') { + throw new ValidationError('El empleado ya esta dado de baja'); + } + + await query( + `UPDATE hr.employees SET + status = 'terminated', + termination_date = $1, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $3 AND tenant_id = $4`, + [terminationDate, userId, id, tenantId] + ); + + // Also terminate active contracts + await query( + `UPDATE hr.contracts SET + status = 'terminated', + date_end = $1, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE employee_id = $3 AND status = 'active'`, + [terminationDate, userId, id] + ); + + return this.findById(id, tenantId); + } + + async reactivate(id: string, tenantId: string, userId: string): Promise { + const employee = await this.findById(id, tenantId); + + if (employee.status !== 'terminated' && employee.status !== 'inactive') { + throw new ValidationError('Solo se pueden reactivar empleados inactivos o dados de baja'); + } + + await query( + `UPDATE hr.employees SET + status = 'active', + termination_date = NULL, + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + const employee = await this.findById(id, tenantId); + + // Check if employee has contracts + const hasContracts = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM hr.contracts WHERE employee_id = $1`, + [id] + ); + + if (parseInt(hasContracts?.count || '0') > 0) { + throw new ConflictError('No se puede eliminar un empleado con contratos asociados'); + } + + // Check if employee is a manager + const isManager = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM hr.employees WHERE manager_id = $1`, + [id] + ); + + if (parseInt(isManager?.count || '0') > 0) { + throw new ConflictError('No se puede eliminar un empleado que es manager de otros'); + } + + await query(`DELETE FROM hr.employees WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } + + // Get subordinates + async getSubordinates(id: string, tenantId: string): Promise { + await this.findById(id, tenantId); + + return query( + `SELECT e.*, + CONCAT(e.first_name, ' ', COALESCE(e.middle_name || ' ', ''), e.last_name) as full_name, + d.name as department_name, + j.name as job_position_name + FROM hr.employees e + LEFT JOIN hr.departments d ON e.department_id = d.id + LEFT JOIN hr.job_positions j ON e.job_position_id = j.id + WHERE e.manager_id = $1 AND e.tenant_id = $2 + ORDER BY e.last_name, e.first_name`, + [id, tenantId] + ); + } +} + +export const employeesService = new EmployeesService(); diff --git a/src/modules/hr/hr.controller.ts b/src/modules/hr/hr.controller.ts new file mode 100644 index 0000000..382c30d --- /dev/null +++ b/src/modules/hr/hr.controller.ts @@ -0,0 +1,721 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { employeesService, CreateEmployeeDto, UpdateEmployeeDto, EmployeeFilters } from './employees.service.js'; +import { departmentsService, CreateDepartmentDto, UpdateDepartmentDto, DepartmentFilters, CreateJobPositionDto, UpdateJobPositionDto } from './departments.service.js'; +import { contractsService, CreateContractDto, UpdateContractDto, ContractFilters } from './contracts.service.js'; +import { leavesService, CreateLeaveDto, UpdateLeaveDto, LeaveFilters, CreateLeaveTypeDto, UpdateLeaveTypeDto } from './leaves.service.js'; +import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js'; +import { ValidationError } from '../../shared/errors/index.js'; + +// Employee schemas +const createEmployeeSchema = z.object({ + company_id: z.string().uuid(), + employee_number: z.string().min(1).max(50), + first_name: z.string().min(1).max(100), + last_name: z.string().min(1).max(100), + middle_name: z.string().max(100).optional(), + user_id: z.string().uuid().optional(), + birth_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + gender: z.string().max(20).optional(), + marital_status: z.string().max(20).optional(), + nationality: z.string().max(100).optional(), + identification_id: z.string().max(50).optional(), + identification_type: z.string().max(50).optional(), + social_security_number: z.string().max(50).optional(), + tax_id: z.string().max(50).optional(), + email: z.string().email().max(255).optional(), + work_email: z.string().email().max(255).optional(), + phone: z.string().max(50).optional(), + work_phone: z.string().max(50).optional(), + mobile: z.string().max(50).optional(), + emergency_contact: z.string().max(255).optional(), + emergency_phone: z.string().max(50).optional(), + street: z.string().max(255).optional(), + city: z.string().max(100).optional(), + state: z.string().max(100).optional(), + zip: z.string().max(20).optional(), + country: z.string().max(100).optional(), + department_id: z.string().uuid().optional(), + job_position_id: z.string().uuid().optional(), + manager_id: z.string().uuid().optional(), + hire_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + bank_name: z.string().max(100).optional(), + bank_account: z.string().max(50).optional(), + bank_clabe: z.string().max(20).optional(), + photo_url: z.string().url().max(500).optional(), + notes: z.string().optional(), +}); + +const updateEmployeeSchema = createEmployeeSchema.partial().omit({ company_id: true, employee_number: true, hire_date: true }); + +const employeeQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + department_id: z.string().uuid().optional(), + status: z.enum(['active', 'inactive', 'on_leave', 'terminated']).optional(), + manager_id: z.string().uuid().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +// Department schemas +const createDepartmentSchema = z.object({ + company_id: z.string().uuid(), + name: z.string().min(1).max(100), + code: z.string().max(20).optional(), + parent_id: z.string().uuid().optional(), + manager_id: z.string().uuid().optional(), + description: z.string().optional(), + color: z.string().max(20).optional(), +}); + +const updateDepartmentSchema = z.object({ + name: z.string().min(1).max(100).optional(), + code: z.string().max(20).optional().nullable(), + parent_id: z.string().uuid().optional().nullable(), + manager_id: z.string().uuid().optional().nullable(), + description: z.string().optional().nullable(), + color: z.string().max(20).optional().nullable(), + active: z.boolean().optional(), +}); + +const departmentQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + active: z.coerce.boolean().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(50), +}); + +// Job Position schemas +const createJobPositionSchema = z.object({ + name: z.string().min(1).max(100), + department_id: z.string().uuid().optional(), + description: z.string().optional(), + requirements: z.string().optional(), + responsibilities: z.string().optional(), + min_salary: z.number().min(0).optional(), + max_salary: z.number().min(0).optional(), +}); + +const updateJobPositionSchema = z.object({ + name: z.string().min(1).max(100).optional(), + department_id: z.string().uuid().optional().nullable(), + description: z.string().optional().nullable(), + requirements: z.string().optional().nullable(), + responsibilities: z.string().optional().nullable(), + min_salary: z.number().min(0).optional().nullable(), + max_salary: z.number().min(0).optional().nullable(), + active: z.boolean().optional(), +}); + +// Contract schemas +const createContractSchema = z.object({ + company_id: z.string().uuid(), + employee_id: z.string().uuid(), + name: z.string().min(1).max(100), + reference: z.string().max(100).optional(), + contract_type: z.enum(['permanent', 'temporary', 'contractor', 'internship', 'part_time']), + job_position_id: z.string().uuid().optional(), + department_id: z.string().uuid().optional(), + date_start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + date_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + trial_date_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + wage: z.number().min(0), + wage_type: z.string().max(20).optional(), + currency_id: z.string().uuid().optional(), + hours_per_week: z.number().min(0).max(168).optional(), + vacation_days: z.number().int().min(0).optional(), + christmas_bonus_days: z.number().int().min(0).optional(), + document_url: z.string().url().max(500).optional(), + notes: z.string().optional(), +}); + +const updateContractSchema = z.object({ + reference: z.string().max(100).optional().nullable(), + job_position_id: z.string().uuid().optional().nullable(), + department_id: z.string().uuid().optional().nullable(), + date_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), + trial_date_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), + wage: z.number().min(0).optional(), + wage_type: z.string().max(20).optional(), + currency_id: z.string().uuid().optional().nullable(), + hours_per_week: z.number().min(0).max(168).optional(), + vacation_days: z.number().int().min(0).optional(), + christmas_bonus_days: z.number().int().min(0).optional(), + document_url: z.string().url().max(500).optional().nullable(), + notes: z.string().optional().nullable(), +}); + +const contractQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + employee_id: z.string().uuid().optional(), + status: z.enum(['draft', 'active', 'expired', 'terminated', 'cancelled']).optional(), + contract_type: z.enum(['permanent', 'temporary', 'contractor', 'internship', 'part_time']).optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +// Leave Type schemas +const createLeaveTypeSchema = z.object({ + name: z.string().min(1).max(100), + code: z.string().max(20).optional(), + leave_type: z.enum(['vacation', 'sick', 'personal', 'maternity', 'paternity', 'bereavement', 'unpaid', 'other']), + requires_approval: z.boolean().optional(), + max_days: z.number().int().min(1).optional(), + is_paid: z.boolean().optional(), + color: z.string().max(20).optional(), +}); + +const updateLeaveTypeSchema = z.object({ + name: z.string().min(1).max(100).optional(), + code: z.string().max(20).optional().nullable(), + requires_approval: z.boolean().optional(), + max_days: z.number().int().min(1).optional().nullable(), + is_paid: z.boolean().optional(), + color: z.string().max(20).optional().nullable(), + active: z.boolean().optional(), +}); + +// Leave schemas +const createLeaveSchema = z.object({ + company_id: z.string().uuid(), + employee_id: z.string().uuid(), + leave_type_id: z.string().uuid(), + name: z.string().max(255).optional(), + date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + description: z.string().optional(), +}); + +const updateLeaveSchema = z.object({ + leave_type_id: z.string().uuid().optional(), + name: z.string().max(255).optional().nullable(), + date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + description: z.string().optional().nullable(), +}); + +const leaveQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + employee_id: z.string().uuid().optional(), + leave_type_id: z.string().uuid().optional(), + status: z.enum(['draft', 'submitted', 'approved', 'rejected', 'cancelled']).optional(), + date_from: z.string().optional(), + date_to: z.string().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +const terminateSchema = z.object({ + termination_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), +}); + +const rejectSchema = z.object({ + reason: z.string().min(1), +}); + +class HrController { + // ========== EMPLOYEES ========== + + async getEmployees(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = employeeQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors); + } + + const filters: EmployeeFilters = queryResult.data; + const result = await employeesService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) }, + }); + } catch (error) { + next(error); + } + } + + async getEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const employee = await employeesService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: employee }); + } catch (error) { + next(error); + } + } + + async createEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createEmployeeSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de empleado invalidos', parseResult.error.errors); + } + + const dto: CreateEmployeeDto = parseResult.data; + const employee = await employeesService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ success: true, data: employee, message: 'Empleado creado exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateEmployeeSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de empleado invalidos', parseResult.error.errors); + } + + const dto: UpdateEmployeeDto = parseResult.data; + const employee = await employeesService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ success: true, data: employee, message: 'Empleado actualizado exitosamente' }); + } catch (error) { + next(error); + } + } + + async terminateEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = terminateSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos invalidos', parseResult.error.errors); + } + + const employee = await employeesService.terminate(req.params.id, parseResult.data.termination_date, req.tenantId!, req.user!.userId); + res.json({ success: true, data: employee, message: 'Empleado dado de baja exitosamente' }); + } catch (error) { + next(error); + } + } + + async reactivateEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const employee = await employeesService.reactivate(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: employee, message: 'Empleado reactivado exitosamente' }); + } catch (error) { + next(error); + } + } + + async deleteEmployee(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await employeesService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Empleado eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + async getSubordinates(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const subordinates = await employeesService.getSubordinates(req.params.id, req.tenantId!); + res.json({ success: true, data: subordinates }); + } catch (error) { + next(error); + } + } + + // ========== DEPARTMENTS ========== + + async getDepartments(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = departmentQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors); + } + + const filters: DepartmentFilters = queryResult.data; + const result = await departmentsService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 50)) }, + }); + } catch (error) { + next(error); + } + } + + async getDepartment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const department = await departmentsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: department }); + } catch (error) { + next(error); + } + } + + async createDepartment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createDepartmentSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de departamento invalidos', parseResult.error.errors); + } + + const dto: CreateDepartmentDto = parseResult.data; + const department = await departmentsService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ success: true, data: department, message: 'Departamento creado exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateDepartment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateDepartmentSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de departamento invalidos', parseResult.error.errors); + } + + const dto: UpdateDepartmentDto = parseResult.data; + const department = await departmentsService.update(req.params.id, dto, req.tenantId!); + + res.json({ success: true, data: department, message: 'Departamento actualizado exitosamente' }); + } catch (error) { + next(error); + } + } + + async deleteDepartment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await departmentsService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Departamento eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== JOB POSITIONS ========== + + async getJobPositions(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const includeInactive = req.query.include_inactive === 'true'; + const positions = await departmentsService.getJobPositions(req.tenantId!, includeInactive); + res.json({ success: true, data: positions }); + } catch (error) { + next(error); + } + } + + async createJobPosition(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createJobPositionSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de puesto invalidos', parseResult.error.errors); + } + + const dto: CreateJobPositionDto = parseResult.data; + const position = await departmentsService.createJobPosition(dto, req.tenantId!); + + res.status(201).json({ success: true, data: position, message: 'Puesto creado exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateJobPosition(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateJobPositionSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de puesto invalidos', parseResult.error.errors); + } + + const dto: UpdateJobPositionDto = parseResult.data; + const position = await departmentsService.updateJobPosition(req.params.id, dto, req.tenantId!); + + res.json({ success: true, data: position, message: 'Puesto actualizado exitosamente' }); + } catch (error) { + next(error); + } + } + + async deleteJobPosition(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await departmentsService.deleteJobPosition(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Puesto eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== CONTRACTS ========== + + async getContracts(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = contractQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors); + } + + const filters: ContractFilters = queryResult.data; + const result = await contractsService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) }, + }); + } catch (error) { + next(error); + } + } + + async getContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const contract = await contractsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: contract }); + } catch (error) { + next(error); + } + } + + async createContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createContractSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de contrato invalidos', parseResult.error.errors); + } + + const dto: CreateContractDto = parseResult.data; + const contract = await contractsService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ success: true, data: contract, message: 'Contrato creado exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateContractSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de contrato invalidos', parseResult.error.errors); + } + + const dto: UpdateContractDto = parseResult.data; + const contract = await contractsService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ success: true, data: contract, message: 'Contrato actualizado exitosamente' }); + } catch (error) { + next(error); + } + } + + async activateContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const contract = await contractsService.activate(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: contract, message: 'Contrato activado exitosamente' }); + } catch (error) { + next(error); + } + } + + async terminateContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = terminateSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos invalidos', parseResult.error.errors); + } + + const contract = await contractsService.terminate(req.params.id, parseResult.data.termination_date, req.tenantId!, req.user!.userId); + res.json({ success: true, data: contract, message: 'Contrato terminado exitosamente' }); + } catch (error) { + next(error); + } + } + + async cancelContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const contract = await contractsService.cancel(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: contract, message: 'Contrato cancelado exitosamente' }); + } catch (error) { + next(error); + } + } + + async deleteContract(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await contractsService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Contrato eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== LEAVE TYPES ========== + + async getLeaveTypes(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const includeInactive = req.query.include_inactive === 'true'; + const leaveTypes = await leavesService.getLeaveTypes(req.tenantId!, includeInactive); + res.json({ success: true, data: leaveTypes }); + } catch (error) { + next(error); + } + } + + async createLeaveType(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createLeaveTypeSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de tipo de ausencia invalidos', parseResult.error.errors); + } + + const dto: CreateLeaveTypeDto = parseResult.data; + const leaveType = await leavesService.createLeaveType(dto, req.tenantId!); + + res.status(201).json({ success: true, data: leaveType, message: 'Tipo de ausencia creado exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateLeaveType(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateLeaveTypeSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de tipo de ausencia invalidos', parseResult.error.errors); + } + + const dto: UpdateLeaveTypeDto = parseResult.data; + const leaveType = await leavesService.updateLeaveType(req.params.id, dto, req.tenantId!); + + res.json({ success: true, data: leaveType, message: 'Tipo de ausencia actualizado exitosamente' }); + } catch (error) { + next(error); + } + } + + async deleteLeaveType(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await leavesService.deleteLeaveType(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Tipo de ausencia eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== LEAVES ========== + + async getLeaves(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = leaveQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parametros de consulta invalidos', queryResult.error.errors); + } + + const filters: LeaveFilters = queryResult.data; + const result = await leavesService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) }, + }); + } catch (error) { + next(error); + } + } + + async getLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const leave = await leavesService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: leave }); + } catch (error) { + next(error); + } + } + + async createLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createLeaveSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de ausencia invalidos', parseResult.error.errors); + } + + const dto: CreateLeaveDto = parseResult.data; + const leave = await leavesService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ success: true, data: leave, message: 'Solicitud de ausencia creada exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateLeaveSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de ausencia invalidos', parseResult.error.errors); + } + + const dto: UpdateLeaveDto = parseResult.data; + const leave = await leavesService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ success: true, data: leave, message: 'Solicitud de ausencia actualizada exitosamente' }); + } catch (error) { + next(error); + } + } + + async submitLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const leave = await leavesService.submit(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: leave, message: 'Solicitud enviada exitosamente' }); + } catch (error) { + next(error); + } + } + + async approveLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const leave = await leavesService.approve(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: leave, message: 'Solicitud aprobada exitosamente' }); + } catch (error) { + next(error); + } + } + + async rejectLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = rejectSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos invalidos', parseResult.error.errors); + } + + const leave = await leavesService.reject(req.params.id, parseResult.data.reason, req.tenantId!, req.user!.userId); + res.json({ success: true, data: leave, message: 'Solicitud rechazada' }); + } catch (error) { + next(error); + } + } + + async cancelLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const leave = await leavesService.cancel(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: leave, message: 'Solicitud cancelada exitosamente' }); + } catch (error) { + next(error); + } + } + + async deleteLeave(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await leavesService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Solicitud eliminada exitosamente' }); + } catch (error) { + next(error); + } + } +} + +export const hrController = new HrController(); diff --git a/src/modules/hr/hr.routes.ts b/src/modules/hr/hr.routes.ts new file mode 100644 index 0000000..68a78ed --- /dev/null +++ b/src/modules/hr/hr.routes.ts @@ -0,0 +1,152 @@ +import { Router } from 'express'; +import { hrController } from './hr.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// ========== EMPLOYEES ========== + +router.get('/employees', (req, res, next) => hrController.getEmployees(req, res, next)); + +router.get('/employees/:id', (req, res, next) => hrController.getEmployee(req, res, next)); + +router.get('/employees/:id/subordinates', (req, res, next) => hrController.getSubordinates(req, res, next)); + +router.post('/employees', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + hrController.createEmployee(req, res, next) +); + +router.put('/employees/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + hrController.updateEmployee(req, res, next) +); + +router.post('/employees/:id/terminate', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.terminateEmployee(req, res, next) +); + +router.post('/employees/:id/reactivate', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.reactivateEmployee(req, res, next) +); + +router.delete('/employees/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.deleteEmployee(req, res, next) +); + +// ========== DEPARTMENTS ========== + +router.get('/departments', (req, res, next) => hrController.getDepartments(req, res, next)); + +router.get('/departments/:id', (req, res, next) => hrController.getDepartment(req, res, next)); + +router.post('/departments', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.createDepartment(req, res, next) +); + +router.put('/departments/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.updateDepartment(req, res, next) +); + +router.delete('/departments/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.deleteDepartment(req, res, next) +); + +// ========== JOB POSITIONS ========== + +router.get('/positions', (req, res, next) => hrController.getJobPositions(req, res, next)); + +router.post('/positions', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.createJobPosition(req, res, next) +); + +router.put('/positions/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.updateJobPosition(req, res, next) +); + +router.delete('/positions/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.deleteJobPosition(req, res, next) +); + +// ========== CONTRACTS ========== + +router.get('/contracts', (req, res, next) => hrController.getContracts(req, res, next)); + +router.get('/contracts/:id', (req, res, next) => hrController.getContract(req, res, next)); + +router.post('/contracts', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + hrController.createContract(req, res, next) +); + +router.put('/contracts/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + hrController.updateContract(req, res, next) +); + +router.post('/contracts/:id/activate', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.activateContract(req, res, next) +); + +router.post('/contracts/:id/terminate', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.terminateContract(req, res, next) +); + +router.post('/contracts/:id/cancel', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.cancelContract(req, res, next) +); + +router.delete('/contracts/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.deleteContract(req, res, next) +); + +// ========== LEAVE TYPES ========== + +router.get('/leave-types', (req, res, next) => hrController.getLeaveTypes(req, res, next)); + +router.post('/leave-types', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.createLeaveType(req, res, next) +); + +router.put('/leave-types/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.updateLeaveType(req, res, next) +); + +router.delete('/leave-types/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.deleteLeaveType(req, res, next) +); + +// ========== LEAVES ========== + +router.get('/leaves', (req, res, next) => hrController.getLeaves(req, res, next)); + +router.get('/leaves/:id', (req, res, next) => hrController.getLeave(req, res, next)); + +router.post('/leaves', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + hrController.createLeave(req, res, next) +); + +router.put('/leaves/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + hrController.updateLeave(req, res, next) +); + +router.post('/leaves/:id/submit', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + hrController.submitLeave(req, res, next) +); + +router.post('/leaves/:id/approve', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + hrController.approveLeave(req, res, next) +); + +router.post('/leaves/:id/reject', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + hrController.rejectLeave(req, res, next) +); + +router.post('/leaves/:id/cancel', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + hrController.cancelLeave(req, res, next) +); + +router.delete('/leaves/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + hrController.deleteLeave(req, res, next) +); + +export default router; diff --git a/src/modules/hr/index.ts b/src/modules/hr/index.ts new file mode 100644 index 0000000..1a5223b --- /dev/null +++ b/src/modules/hr/index.ts @@ -0,0 +1,6 @@ +export * from './employees.service.js'; +export * from './departments.service.js'; +export * from './contracts.service.js'; +export * from './leaves.service.js'; +export * from './hr.controller.js'; +export { default as hrRoutes } from './hr.routes.js'; diff --git a/src/modules/hr/leaves.service.ts b/src/modules/hr/leaves.service.ts new file mode 100644 index 0000000..957dd24 --- /dev/null +++ b/src/modules/hr/leaves.service.ts @@ -0,0 +1,517 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js'; + +export type LeaveStatus = 'draft' | 'submitted' | 'approved' | 'rejected' | 'cancelled'; +export type LeaveType = 'vacation' | 'sick' | 'personal' | 'maternity' | 'paternity' | 'bereavement' | 'unpaid' | 'other'; + +export interface LeaveTypeConfig { + id: string; + tenant_id: string; + name: string; + code?: string; + leave_type: LeaveType; + requires_approval: boolean; + max_days?: number; + is_paid: boolean; + color?: string; + active: boolean; + created_at: Date; +} + +export interface Leave { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + employee_id: string; + employee_name?: string; + employee_number?: string; + leave_type_id: string; + leave_type_name?: string; + name?: string; + date_from: Date; + date_to: Date; + number_of_days: number; + status: LeaveStatus; + description?: string; + approved_by?: string; + approved_by_name?: string; + approved_at?: Date; + rejection_reason?: string; + created_at: Date; +} + +export interface CreateLeaveTypeDto { + name: string; + code?: string; + leave_type: LeaveType; + requires_approval?: boolean; + max_days?: number; + is_paid?: boolean; + color?: string; +} + +export interface UpdateLeaveTypeDto { + name?: string; + code?: string | null; + requires_approval?: boolean; + max_days?: number | null; + is_paid?: boolean; + color?: string | null; + active?: boolean; +} + +export interface CreateLeaveDto { + company_id: string; + employee_id: string; + leave_type_id: string; + name?: string; + date_from: string; + date_to: string; + description?: string; +} + +export interface UpdateLeaveDto { + leave_type_id?: string; + name?: string | null; + date_from?: string; + date_to?: string; + description?: string | null; +} + +export interface LeaveFilters { + company_id?: string; + employee_id?: string; + leave_type_id?: string; + status?: LeaveStatus; + date_from?: string; + date_to?: string; + search?: string; + page?: number; + limit?: number; +} + +class LeavesService { + // ========== LEAVE TYPES ========== + + async getLeaveTypes(tenantId: string, includeInactive = false): Promise { + let whereClause = 'WHERE tenant_id = $1'; + if (!includeInactive) { + whereClause += ' AND active = TRUE'; + } + + return query( + `SELECT * FROM hr.leave_types ${whereClause} ORDER BY name`, + [tenantId] + ); + } + + async getLeaveTypeById(id: string, tenantId: string): Promise { + const leaveType = await queryOne( + `SELECT * FROM hr.leave_types WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + if (!leaveType) { + throw new NotFoundError('Tipo de ausencia no encontrado'); + } + + return leaveType; + } + + async createLeaveType(dto: CreateLeaveTypeDto, tenantId: string): Promise { + const existing = await queryOne( + `SELECT id FROM hr.leave_types WHERE name = $1 AND tenant_id = $2`, + [dto.name, tenantId] + ); + + if (existing) { + throw new ConflictError('Ya existe un tipo de ausencia con ese nombre'); + } + + const leaveType = await queryOne( + `INSERT INTO hr.leave_types (tenant_id, name, code, leave_type, requires_approval, max_days, is_paid, color) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [ + tenantId, dto.name, dto.code, dto.leave_type, + dto.requires_approval ?? true, dto.max_days, dto.is_paid ?? true, dto.color + ] + ); + + return leaveType!; + } + + async updateLeaveType(id: string, dto: UpdateLeaveTypeDto, tenantId: string): Promise { + const existing = await this.getLeaveTypeById(id, tenantId); + + if (dto.name && dto.name !== existing.name) { + const nameExists = await queryOne( + `SELECT id FROM hr.leave_types WHERE name = $1 AND tenant_id = $2 AND id != $3`, + [dto.name, tenantId, id] + ); + if (nameExists) { + throw new ConflictError('Ya existe un tipo de ausencia con ese nombre'); + } + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + const fieldsToUpdate = ['name', 'code', 'requires_approval', 'max_days', 'is_paid', 'color', 'active']; + + for (const field of fieldsToUpdate) { + if ((dto as any)[field] !== undefined) { + updateFields.push(`${field} = $${paramIndex++}`); + values.push((dto as any)[field]); + } + } + + if (updateFields.length === 0) { + return existing; + } + + values.push(id, tenantId); + + await query( + `UPDATE hr.leave_types SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.getLeaveTypeById(id, tenantId); + } + + async deleteLeaveType(id: string, tenantId: string): Promise { + await this.getLeaveTypeById(id, tenantId); + + const inUse = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM hr.leaves WHERE leave_type_id = $1`, + [id] + ); + + if (parseInt(inUse?.count || '0') > 0) { + throw new ConflictError('No se puede eliminar un tipo de ausencia que esta en uso'); + } + + await query(`DELETE FROM hr.leave_types WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } + + // ========== LEAVES ========== + + async findAll(tenantId: string, filters: LeaveFilters = {}): Promise<{ data: Leave[]; total: number }> { + const { company_id, employee_id, leave_type_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE l.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND l.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (employee_id) { + whereClause += ` AND l.employee_id = $${paramIndex++}`; + params.push(employee_id); + } + + if (leave_type_id) { + whereClause += ` AND l.leave_type_id = $${paramIndex++}`; + params.push(leave_type_id); + } + + if (status) { + whereClause += ` AND l.status = $${paramIndex++}`; + params.push(status); + } + + if (date_from) { + whereClause += ` AND l.date_from >= $${paramIndex++}`; + params.push(date_from); + } + + if (date_to) { + whereClause += ` AND l.date_to <= $${paramIndex++}`; + params.push(date_to); + } + + if (search) { + whereClause += ` AND (l.name ILIKE $${paramIndex} OR e.first_name ILIKE $${paramIndex} OR e.last_name ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count + FROM hr.leaves l + LEFT JOIN hr.employees e ON l.employee_id = e.id + ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT l.*, + c.name as company_name, + CONCAT(e.first_name, ' ', e.last_name) as employee_name, + e.employee_number, + lt.name as leave_type_name, + CONCAT(a.first_name, ' ', a.last_name) as approved_by_name + FROM hr.leaves l + LEFT JOIN auth.companies c ON l.company_id = c.id + LEFT JOIN hr.employees e ON l.employee_id = e.id + LEFT JOIN hr.leave_types lt ON l.leave_type_id = lt.id + LEFT JOIN hr.employees a ON l.approved_by = a.user_id + ${whereClause} + ORDER BY l.date_from DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const leave = await queryOne( + `SELECT l.*, + c.name as company_name, + CONCAT(e.first_name, ' ', e.last_name) as employee_name, + e.employee_number, + lt.name as leave_type_name, + CONCAT(a.first_name, ' ', a.last_name) as approved_by_name + FROM hr.leaves l + LEFT JOIN auth.companies c ON l.company_id = c.id + LEFT JOIN hr.employees e ON l.employee_id = e.id + LEFT JOIN hr.leave_types lt ON l.leave_type_id = lt.id + LEFT JOIN hr.employees a ON l.approved_by = a.user_id + WHERE l.id = $1 AND l.tenant_id = $2`, + [id, tenantId] + ); + + if (!leave) { + throw new NotFoundError('Solicitud de ausencia no encontrada'); + } + + return leave; + } + + async create(dto: CreateLeaveDto, tenantId: string, userId: string): Promise { + // Calculate number of days + const startDate = new Date(dto.date_from); + const endDate = new Date(dto.date_to); + const diffTime = Math.abs(endDate.getTime() - startDate.getTime()); + const numberOfDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; + + if (numberOfDays <= 0) { + throw new ValidationError('La fecha de fin debe ser igual o posterior a la fecha de inicio'); + } + + // Check leave type max days + const leaveType = await this.getLeaveTypeById(dto.leave_type_id, tenantId); + if (leaveType.max_days && numberOfDays > leaveType.max_days) { + throw new ValidationError(`Este tipo de ausencia tiene un maximo de ${leaveType.max_days} dias`); + } + + // Check for overlapping leaves + const overlap = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM hr.leaves + WHERE employee_id = $1 AND status IN ('submitted', 'approved') + AND ((date_from <= $2 AND date_to >= $2) OR (date_from <= $3 AND date_to >= $3) + OR (date_from >= $2 AND date_to <= $3))`, + [dto.employee_id, dto.date_from, dto.date_to] + ); + + if (parseInt(overlap?.count || '0') > 0) { + throw new ValidationError('Ya existe una solicitud de ausencia para estas fechas'); + } + + const leave = await queryOne( + `INSERT INTO hr.leaves ( + tenant_id, company_id, employee_id, leave_type_id, name, date_from, date_to, + number_of_days, description, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [ + tenantId, dto.company_id, dto.employee_id, dto.leave_type_id, dto.name, + dto.date_from, dto.date_to, numberOfDays, dto.description, userId + ] + ); + + return this.findById(leave!.id, tenantId); + } + + async update(id: string, dto: UpdateLeaveDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'draft') { + throw new ValidationError('Solo se pueden editar solicitudes en borrador'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.leave_type_id !== undefined) { + updateFields.push(`leave_type_id = $${paramIndex++}`); + values.push(dto.leave_type_id); + } + if (dto.name !== undefined) { + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.description !== undefined) { + updateFields.push(`description = $${paramIndex++}`); + values.push(dto.description); + } + + // Recalculate days if dates changed + let newDateFrom = existing.date_from; + let newDateTo = existing.date_to; + + if (dto.date_from !== undefined) { + updateFields.push(`date_from = $${paramIndex++}`); + values.push(dto.date_from); + newDateFrom = new Date(dto.date_from); + } + if (dto.date_to !== undefined) { + updateFields.push(`date_to = $${paramIndex++}`); + values.push(dto.date_to); + newDateTo = new Date(dto.date_to); + } + + if (dto.date_from !== undefined || dto.date_to !== undefined) { + const diffTime = Math.abs(newDateTo.getTime() - newDateFrom.getTime()); + const numberOfDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; + updateFields.push(`number_of_days = $${paramIndex++}`); + values.push(numberOfDays); + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + + values.push(id, tenantId); + + await query( + `UPDATE hr.leaves SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async submit(id: string, tenantId: string, userId: string): Promise { + const leave = await this.findById(id, tenantId); + + if (leave.status !== 'draft') { + throw new ValidationError('Solo se pueden enviar solicitudes en borrador'); + } + + await query( + `UPDATE hr.leaves SET + status = 'submitted', + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async approve(id: string, tenantId: string, userId: string): Promise { + const leave = await this.findById(id, tenantId); + + if (leave.status !== 'submitted') { + throw new ValidationError('Solo se pueden aprobar solicitudes enviadas'); + } + + await query( + `UPDATE hr.leaves SET + status = 'approved', + approved_by = $1, + approved_at = CURRENT_TIMESTAMP, + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + // Update employee status if leave starts today or earlier + const today = new Date().toISOString().split('T')[0]; + if (leave.date_from.toISOString().split('T')[0] <= today && leave.date_to.toISOString().split('T')[0] >= today) { + await query( + `UPDATE hr.employees SET status = 'on_leave' WHERE id = $1`, + [leave.employee_id] + ); + } + + return this.findById(id, tenantId); + } + + async reject(id: string, reason: string, tenantId: string, userId: string): Promise { + const leave = await this.findById(id, tenantId); + + if (leave.status !== 'submitted') { + throw new ValidationError('Solo se pueden rechazar solicitudes enviadas'); + } + + await query( + `UPDATE hr.leaves SET + status = 'rejected', + rejection_reason = $1, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $3 AND tenant_id = $4`, + [reason, userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async cancel(id: string, tenantId: string, userId: string): Promise { + const leave = await this.findById(id, tenantId); + + if (leave.status === 'cancelled') { + throw new ValidationError('La solicitud ya esta cancelada'); + } + + if (leave.status === 'rejected') { + throw new ValidationError('No se puede cancelar una solicitud rechazada'); + } + + await query( + `UPDATE hr.leaves SET + status = 'cancelled', + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + const leave = await this.findById(id, tenantId); + + if (leave.status !== 'draft') { + throw new ValidationError('Solo se pueden eliminar solicitudes en borrador'); + } + + await query(`DELETE FROM hr.leaves WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } +} + +export const leavesService = new LeavesService(); diff --git a/src/modules/inventory/MIGRATION_STATUS.md b/src/modules/inventory/MIGRATION_STATUS.md new file mode 100644 index 0000000..90f2310 --- /dev/null +++ b/src/modules/inventory/MIGRATION_STATUS.md @@ -0,0 +1,177 @@ +# Inventory Module TypeORM Migration Status + +## Completed Tasks + +### 1. Entity Creation (100% Complete) +All entity files have been successfully created in `/src/modules/inventory/entities/`: + +- ✅ `product.entity.ts` - Product entity with types, tracking, and valuation methods +- ✅ `warehouse.entity.ts` - Warehouse entity with company relation +- ✅ `location.entity.ts` - Location entity with hierarchy support +- ✅ `stock-quant.entity.ts` - Stock quantities per location +- ✅ `lot.entity.ts` - Lot/batch tracking +- ✅ `picking.entity.ts` - Picking/fulfillment operations +- ✅ `stock-move.entity.ts` - Stock movement lines +- ✅ `inventory-adjustment.entity.ts` - Stock adjustments header +- ✅ `inventory-adjustment-line.entity.ts` - Stock adjustment lines +- ✅ `stock-valuation-layer.entity.ts` - FIFO/Average cost valuation + +All entities include: +- Proper schema specification (`schema: 'inventory'`) +- Indexes on key fields +- Relations using TypeORM decorators +- Audit fields (created_at, created_by, updated_at, updated_by, deleted_at, deleted_by) +- Enums for type-safe status fields + +### 2. Service Refactoring (Partial - 2/8 Complete) + +#### ✅ Completed Services: +1. **products.service.ts** - Fully migrated to TypeORM + - Uses Repository pattern + - All CRUD operations converted + - Proper error handling and logging + - Stock validation before deletion + +2. **warehouses.service.ts** - Fully migrated to TypeORM + - Company relations properly loaded + - Default warehouse handling + - Stock validation + - Location and stock retrieval + +#### ⏳ Remaining Services to Migrate: +3. **locations.service.ts** - Needs TypeORM migration + - Current: Uses raw SQL queries + - Todo: Convert to Repository pattern with QueryBuilder + - Key features: Hierarchical locations, parent-child relationships + +4. **lots.service.ts** - Needs TypeORM migration + - Current: Uses raw SQL queries + - Todo: Convert to Repository pattern + - Key features: Expiration tracking, stock quantity aggregation + +5. **pickings.service.ts** - Needs TypeORM migration (COMPLEX) + - Current: Uses raw SQL with transactions + - Todo: Convert to TypeORM with QueryRunner for transactions + - Key features: Multi-line operations, status workflows, stock updates + +6. **adjustments.service.ts** - Needs TypeORM migration (COMPLEX) + - Current: Uses raw SQL with transactions + - Todo: Convert to TypeORM with QueryRunner + - Key features: Multi-line operations, theoretical vs counted quantities + +7. **valuation.service.ts** - Needs TypeORM migration (COMPLEX) + - Current: Uses raw SQL with client transactions + - Todo: Convert to TypeORM while maintaining FIFO logic + - Key features: Valuation layer management, FIFO consumption + +8. **stock-quants.service.ts** - NEW SERVICE NEEDED + - Currently no dedicated service (operations are in other services) + - Should handle: Stock queries, reservations, availability checks + +### 3. TypeORM Configuration +- ✅ Entities imported in `/src/config/typeorm.ts` +- ⚠️ **ACTION REQUIRED**: Add entities to the `entities` array in AppDataSource configuration + +Add these lines after `FiscalPeriod,` in the entities array: +```typescript + // Inventory Entities + Product, + Warehouse, + Location, + StockQuant, + Lot, + Picking, + StockMove, + InventoryAdjustment, + InventoryAdjustmentLine, + StockValuationLayer, +``` + +### 4. Controller Updates +- ⏳ **inventory.controller.ts** - Needs snake_case/camelCase handling + - Current: Only accepts snake_case from frontend + - Todo: Add transformers or accept both formats + - Pattern: Use class-transformer decorators or manual mapping + +### 5. Index File +- ✅ Created `/src/modules/inventory/entities/index.ts` - Exports all entities + +## Migration Patterns Used + +### Repository Pattern +```typescript +class ProductsService { + private productRepository: Repository; + + constructor() { + this.productRepository = AppDataSource.getRepository(Product); + } +} +``` + +### QueryBuilder for Complex Queries +```typescript +const products = await this.productRepository + .createQueryBuilder('product') + .where('product.tenantId = :tenantId', { tenantId }) + .andWhere('product.deletedAt IS NULL') + .getMany(); +``` + +### Relations Loading +```typescript +.leftJoinAndSelect('warehouse.company', 'company') +``` + +### Error Handling +```typescript +try { + // operations +} catch (error) { + logger.error('Error message', { error, context }); + throw error; +} +``` + +## Remaining Work + +### High Priority +1. **Add entities to typeorm.ts entities array** (Manual edit required) +2. **Migrate locations.service.ts** - Simple, good next step +3. **Migrate lots.service.ts** - Simple, includes aggregations + +### Medium Priority +4. **Create stock-quants.service.ts** - New service for stock operations +5. **Migrate pickings.service.ts** - Complex transactions +6. **Migrate adjustments.service.ts** - Complex transactions + +### Lower Priority +7. **Migrate valuation.service.ts** - Most complex, FIFO logic +8. **Update controller for case handling** - Nice to have +9. **Add integration tests** - Verify TypeORM migration works correctly + +## Testing Checklist + +After completing migration: +- [ ] Test product CRUD operations +- [ ] Test warehouse operations with company relations +- [ ] Test stock queries with filters +- [ ] Test multi-level location hierarchies +- [ ] Test lot expiration tracking +- [ ] Test picking workflows (draft → confirmed → done) +- [ ] Test inventory adjustments with stock updates +- [ ] Test FIFO valuation consumption +- [ ] Test transaction rollbacks on errors +- [ ] Performance test: Compare query performance vs raw SQL + +## Notes + +- All entities use the `inventory` schema +- Soft deletes are implemented for products (deletedAt field) +- Hard deletes are used for other entities where appropriate +- Audit trails are maintained (created_by, updated_by, etc.) +- Foreign keys properly set up with @JoinColumn decorators +- Indexes added on frequently queried fields + +## Breaking Changes +None - The migration maintains API compatibility. All DTOs use camelCase internally but accept snake_case from the original queries. diff --git a/src/modules/inventory/adjustments.service.ts b/src/modules/inventory/adjustments.service.ts new file mode 100644 index 0000000..d6286f7 --- /dev/null +++ b/src/modules/inventory/adjustments.service.ts @@ -0,0 +1,512 @@ +import { query, queryOne, getClient } from '../../config/database.js'; +import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js'; + +export type AdjustmentStatus = 'draft' | 'confirmed' | 'done' | 'cancelled'; + +export interface AdjustmentLine { + id: string; + adjustment_id: string; + product_id: string; + product_name?: string; + product_code?: string; + location_id: string; + location_name?: string; + lot_id?: string; + lot_name?: string; + theoretical_qty: number; + counted_qty: number; + difference_qty: number; + uom_id: string; + uom_name?: string; + notes?: string; + created_at: Date; +} + +export interface Adjustment { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + name: string; + location_id: string; + location_name?: string; + date: Date; + status: AdjustmentStatus; + notes?: string; + lines?: AdjustmentLine[]; + created_at: Date; +} + +export interface CreateAdjustmentLineDto { + product_id: string; + location_id: string; + lot_id?: string; + counted_qty: number; + uom_id: string; + notes?: string; +} + +export interface CreateAdjustmentDto { + company_id: string; + location_id: string; + date?: string; + notes?: string; + lines: CreateAdjustmentLineDto[]; +} + +export interface UpdateAdjustmentDto { + location_id?: string; + date?: string; + notes?: string | null; +} + +export interface UpdateAdjustmentLineDto { + counted_qty?: number; + notes?: string | null; +} + +export interface AdjustmentFilters { + company_id?: string; + location_id?: string; + status?: AdjustmentStatus; + date_from?: string; + date_to?: string; + search?: string; + page?: number; + limit?: number; +} + +class AdjustmentsService { + async findAll(tenantId: string, filters: AdjustmentFilters = {}): Promise<{ data: Adjustment[]; total: number }> { + const { company_id, location_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE a.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND a.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (location_id) { + whereClause += ` AND a.location_id = $${paramIndex++}`; + params.push(location_id); + } + + if (status) { + whereClause += ` AND a.status = $${paramIndex++}`; + params.push(status); + } + + if (date_from) { + whereClause += ` AND a.date >= $${paramIndex++}`; + params.push(date_from); + } + + if (date_to) { + whereClause += ` AND a.date <= $${paramIndex++}`; + params.push(date_to); + } + + if (search) { + whereClause += ` AND (a.name ILIKE $${paramIndex} OR a.notes ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM inventory.inventory_adjustments a ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT a.*, + c.name as company_name, + l.name as location_name + FROM inventory.inventory_adjustments a + LEFT JOIN auth.companies c ON a.company_id = c.id + LEFT JOIN inventory.locations l ON a.location_id = l.id + ${whereClause} + ORDER BY a.date DESC, a.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const adjustment = await queryOne( + `SELECT a.*, + c.name as company_name, + l.name as location_name + FROM inventory.inventory_adjustments a + LEFT JOIN auth.companies c ON a.company_id = c.id + LEFT JOIN inventory.locations l ON a.location_id = l.id + WHERE a.id = $1 AND a.tenant_id = $2`, + [id, tenantId] + ); + + if (!adjustment) { + throw new NotFoundError('Ajuste de inventario no encontrado'); + } + + // Get lines + const lines = await query( + `SELECT al.*, + p.name as product_name, + p.code as product_code, + l.name as location_name, + lot.name as lot_name, + u.name as uom_name + FROM inventory.inventory_adjustment_lines al + LEFT JOIN inventory.products p ON al.product_id = p.id + LEFT JOIN inventory.locations l ON al.location_id = l.id + LEFT JOIN inventory.lots lot ON al.lot_id = lot.id + LEFT JOIN core.uom u ON al.uom_id = u.id + WHERE al.adjustment_id = $1 + ORDER BY al.created_at`, + [id] + ); + + adjustment.lines = lines; + + return adjustment; + } + + async create(dto: CreateAdjustmentDto, tenantId: string, userId: string): Promise { + if (dto.lines.length === 0) { + throw new ValidationError('El ajuste debe tener al menos una línea'); + } + + const client = await getClient(); + + try { + await client.query('BEGIN'); + + // Generate adjustment name + const seqResult = await client.query( + `SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 5) AS INTEGER)), 0) + 1 as next_num + FROM inventory.inventory_adjustments WHERE tenant_id = $1 AND name LIKE 'ADJ-%'`, + [tenantId] + ); + const nextNum = seqResult.rows[0]?.next_num || 1; + const adjustmentName = `ADJ-${String(nextNum).padStart(6, '0')}`; + + const adjustmentDate = dto.date || new Date().toISOString().split('T')[0]; + + // Create adjustment + const adjustmentResult = await client.query( + `INSERT INTO inventory.inventory_adjustments ( + tenant_id, company_id, name, location_id, date, notes, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [tenantId, dto.company_id, adjustmentName, dto.location_id, adjustmentDate, dto.notes, userId] + ); + const adjustment = adjustmentResult.rows[0]; + + // Create lines with theoretical qty from stock_quants + for (const line of dto.lines) { + // Get theoretical quantity from stock_quants + const stockResult = await client.query( + `SELECT COALESCE(SUM(quantity), 0) as qty + FROM inventory.stock_quants + WHERE product_id = $1 AND location_id = $2 + AND ($3::uuid IS NULL OR lot_id = $3)`, + [line.product_id, line.location_id, line.lot_id || null] + ); + const theoreticalQty = parseFloat(stockResult.rows[0]?.qty || '0'); + + await client.query( + `INSERT INTO inventory.inventory_adjustment_lines ( + adjustment_id, tenant_id, product_id, location_id, lot_id, theoretical_qty, + counted_qty + ) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + adjustment.id, tenantId, line.product_id, line.location_id, line.lot_id, + theoreticalQty, line.counted_qty + ] + ); + } + + await client.query('COMMIT'); + + return this.findById(adjustment.id, tenantId); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async update(id: string, dto: UpdateAdjustmentDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'draft') { + throw new ValidationError('Solo se pueden editar ajustes en estado borrador'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.location_id !== undefined) { + updateFields.push(`location_id = $${paramIndex++}`); + values.push(dto.location_id); + } + if (dto.date !== undefined) { + updateFields.push(`date = $${paramIndex++}`); + values.push(dto.date); + } + if (dto.notes !== undefined) { + updateFields.push(`notes = $${paramIndex++}`); + values.push(dto.notes); + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + await query( + `UPDATE inventory.inventory_adjustments SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async addLine(adjustmentId: string, dto: CreateAdjustmentLineDto, tenantId: string): Promise { + const adjustment = await this.findById(adjustmentId, tenantId); + + if (adjustment.status !== 'draft') { + throw new ValidationError('Solo se pueden agregar líneas a ajustes en estado borrador'); + } + + // Get theoretical quantity + const stockResult = await queryOne<{ qty: string }>( + `SELECT COALESCE(SUM(quantity), 0) as qty + FROM inventory.stock_quants + WHERE product_id = $1 AND location_id = $2 + AND ($3::uuid IS NULL OR lot_id = $3)`, + [dto.product_id, dto.location_id, dto.lot_id || null] + ); + const theoreticalQty = parseFloat(stockResult?.qty || '0'); + + const line = await queryOne( + `INSERT INTO inventory.inventory_adjustment_lines ( + adjustment_id, tenant_id, product_id, location_id, lot_id, theoretical_qty, + counted_qty + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [ + adjustmentId, tenantId, dto.product_id, dto.location_id, dto.lot_id, + theoreticalQty, dto.counted_qty + ] + ); + + return line!; + } + + async updateLine(adjustmentId: string, lineId: string, dto: UpdateAdjustmentLineDto, tenantId: string): Promise { + const adjustment = await this.findById(adjustmentId, tenantId); + + if (adjustment.status !== 'draft') { + throw new ValidationError('Solo se pueden editar líneas en ajustes en estado borrador'); + } + + const existingLine = adjustment.lines?.find(l => l.id === lineId); + if (!existingLine) { + throw new NotFoundError('Línea no encontrada'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.counted_qty !== undefined) { + updateFields.push(`counted_qty = $${paramIndex++}`); + values.push(dto.counted_qty); + } + if (dto.notes !== undefined) { + updateFields.push(`notes = $${paramIndex++}`); + values.push(dto.notes); + } + + if (updateFields.length === 0) { + return existingLine; + } + + values.push(lineId); + + const line = await queryOne( + `UPDATE inventory.inventory_adjustment_lines SET ${updateFields.join(', ')} + WHERE id = $${paramIndex} + RETURNING *`, + values + ); + + return line!; + } + + async removeLine(adjustmentId: string, lineId: string, tenantId: string): Promise { + const adjustment = await this.findById(adjustmentId, tenantId); + + if (adjustment.status !== 'draft') { + throw new ValidationError('Solo se pueden eliminar líneas en ajustes en estado borrador'); + } + + const existingLine = adjustment.lines?.find(l => l.id === lineId); + if (!existingLine) { + throw new NotFoundError('Línea no encontrada'); + } + + if (adjustment.lines && adjustment.lines.length <= 1) { + throw new ValidationError('El ajuste debe tener al menos una línea'); + } + + await query(`DELETE FROM inventory.inventory_adjustment_lines WHERE id = $1`, [lineId]); + } + + async confirm(id: string, tenantId: string, userId: string): Promise { + const adjustment = await this.findById(id, tenantId); + + if (adjustment.status !== 'draft') { + throw new ValidationError('Solo se pueden confirmar ajustes en estado borrador'); + } + + if (!adjustment.lines || adjustment.lines.length === 0) { + throw new ValidationError('El ajuste debe tener al menos una línea'); + } + + await query( + `UPDATE inventory.inventory_adjustments SET + status = 'confirmed', + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async validate(id: string, tenantId: string, userId: string): Promise { + const adjustment = await this.findById(id, tenantId); + + if (adjustment.status !== 'confirmed') { + throw new ValidationError('Solo se pueden validar ajustes confirmados'); + } + + const client = await getClient(); + + try { + await client.query('BEGIN'); + + // Update status to done + await client.query( + `UPDATE inventory.inventory_adjustments SET + status = 'done', + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + // Apply stock adjustments + for (const line of adjustment.lines!) { + const difference = line.counted_qty - line.theoretical_qty; + + if (difference !== 0) { + // Check if quant exists + const existingQuant = await client.query( + `SELECT id, quantity FROM inventory.stock_quants + WHERE product_id = $1 AND location_id = $2 + AND ($3::uuid IS NULL OR lot_id = $3)`, + [line.product_id, line.location_id, line.lot_id || null] + ); + + if (existingQuant.rows.length > 0) { + // Update existing quant + await client.query( + `UPDATE inventory.stock_quants SET + quantity = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2`, + [line.counted_qty, existingQuant.rows[0].id] + ); + } else if (line.counted_qty > 0) { + // Create new quant if counted > 0 + await client.query( + `INSERT INTO inventory.stock_quants ( + tenant_id, product_id, location_id, lot_id, quantity + ) + VALUES ($1, $2, $3, $4, $5)`, + [tenantId, line.product_id, line.location_id, line.lot_id, line.counted_qty] + ); + } + } + } + + await client.query('COMMIT'); + + return this.findById(id, tenantId); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async cancel(id: string, tenantId: string, userId: string): Promise { + const adjustment = await this.findById(id, tenantId); + + if (adjustment.status === 'done') { + throw new ValidationError('No se puede cancelar un ajuste validado'); + } + + if (adjustment.status === 'cancelled') { + throw new ValidationError('El ajuste ya está cancelado'); + } + + await query( + `UPDATE inventory.inventory_adjustments SET + status = 'cancelled', + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + const adjustment = await this.findById(id, tenantId); + + if (adjustment.status !== 'draft') { + throw new ValidationError('Solo se pueden eliminar ajustes en estado borrador'); + } + + await query(`DELETE FROM inventory.inventory_adjustments WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } +} + +export const adjustmentsService = new AdjustmentsService(); diff --git a/src/modules/inventory/entities/index.ts b/src/modules/inventory/entities/index.ts new file mode 100644 index 0000000..5a7df30 --- /dev/null +++ b/src/modules/inventory/entities/index.ts @@ -0,0 +1,11 @@ +// Export all inventory entities +export * from './product.entity.js'; +export * from './warehouse.entity.js'; +export * from './location.entity.js'; +export * from './stock-quant.entity.js'; +export * from './lot.entity.js'; +export * from './picking.entity.js'; +export * from './stock-move.entity.js'; +export * from './inventory-adjustment.entity.js'; +export * from './inventory-adjustment-line.entity.js'; +export * from './stock-valuation-layer.entity.js'; diff --git a/src/modules/inventory/entities/inventory-adjustment-line.entity.ts b/src/modules/inventory/entities/inventory-adjustment-line.entity.ts new file mode 100644 index 0000000..0ccd386 --- /dev/null +++ b/src/modules/inventory/entities/inventory-adjustment-line.entity.ts @@ -0,0 +1,80 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { InventoryAdjustment } from './inventory-adjustment.entity.js'; +import { Product } from './product.entity.js'; +import { Location } from './location.entity.js'; +import { Lot } from './lot.entity.js'; + +@Entity({ schema: 'inventory', name: 'inventory_adjustment_lines' }) +@Index('idx_adjustment_lines_adjustment_id', ['adjustmentId']) +@Index('idx_adjustment_lines_product_id', ['productId']) +export class InventoryAdjustmentLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'adjustment_id' }) + adjustmentId: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'product_id' }) + productId: string; + + @Column({ type: 'uuid', nullable: false, name: 'location_id' }) + locationId: string; + + @Column({ type: 'uuid', nullable: true, name: 'lot_id' }) + lotId: string | null; + + @Column({ type: 'decimal', precision: 16, scale: 4, default: 0, name: 'theoretical_qty' }) + theoreticalQty: number; + + @Column({ type: 'decimal', precision: 16, scale: 4, default: 0, name: 'counted_qty' }) + countedQty: number; + + @Column({ + type: 'decimal', + precision: 16, + scale: 4, + nullable: false, + name: 'difference_qty', + generated: 'STORED', + asExpression: 'counted_qty - theoretical_qty', + }) + differenceQty: number; + + @Column({ type: 'uuid', nullable: true, name: 'uom_id' }) + uomId: string | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + // Relations + @ManyToOne(() => InventoryAdjustment, (adjustment) => adjustment.lines, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'adjustment_id' }) + adjustment: InventoryAdjustment; + + @ManyToOne(() => Product) + @JoinColumn({ name: 'product_id' }) + product: Product; + + @ManyToOne(() => Location) + @JoinColumn({ name: 'location_id' }) + location: Location; + + @ManyToOne(() => Lot, { nullable: true }) + @JoinColumn({ name: 'lot_id' }) + lot: Lot | null; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; +} diff --git a/src/modules/inventory/entities/inventory-adjustment.entity.ts b/src/modules/inventory/entities/inventory-adjustment.entity.ts new file mode 100644 index 0000000..2ad84a9 --- /dev/null +++ b/src/modules/inventory/entities/inventory-adjustment.entity.ts @@ -0,0 +1,86 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { Company } from '../../auth/entities/company.entity.js'; +import { Location } from './location.entity.js'; +import { InventoryAdjustmentLine } from './inventory-adjustment-line.entity.js'; + +export enum AdjustmentStatus { + DRAFT = 'draft', + CONFIRMED = 'confirmed', + DONE = 'done', + CANCELLED = 'cancelled', +} + +@Entity({ schema: 'inventory', name: 'inventory_adjustments' }) +@Index('idx_adjustments_tenant_id', ['tenantId']) +@Index('idx_adjustments_company_id', ['companyId']) +@Index('idx_adjustments_status', ['status']) +@Index('idx_adjustments_date', ['date']) +export class InventoryAdjustment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'company_id' }) + companyId: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ type: 'uuid', nullable: false, name: 'location_id' }) + locationId: string; + + @Column({ type: 'date', nullable: false }) + date: Date; + + @Column({ + type: 'enum', + enum: AdjustmentStatus, + default: AdjustmentStatus.DRAFT, + nullable: false, + }) + status: AdjustmentStatus; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + // Relations + @ManyToOne(() => Company) + @JoinColumn({ name: 'company_id' }) + company: Company; + + @ManyToOne(() => Location) + @JoinColumn({ name: 'location_id' }) + location: Location; + + @OneToMany(() => InventoryAdjustmentLine, (line) => line.adjustment) + lines: InventoryAdjustmentLine[]; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; +} diff --git a/src/modules/inventory/entities/location.entity.ts b/src/modules/inventory/entities/location.entity.ts new file mode 100644 index 0000000..9622b72 --- /dev/null +++ b/src/modules/inventory/entities/location.entity.ts @@ -0,0 +1,96 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { Warehouse } from './warehouse.entity.js'; +import { StockQuant } from './stock-quant.entity.js'; + +export enum LocationType { + INTERNAL = 'internal', + SUPPLIER = 'supplier', + CUSTOMER = 'customer', + INVENTORY = 'inventory', + PRODUCTION = 'production', + TRANSIT = 'transit', +} + +@Entity({ schema: 'inventory', name: 'locations' }) +@Index('idx_locations_tenant_id', ['tenantId']) +@Index('idx_locations_warehouse_id', ['warehouseId']) +@Index('idx_locations_parent_id', ['parentId']) +@Index('idx_locations_type', ['locationType']) +export class Location { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: true, name: 'warehouse_id' }) + warehouseId: string | null; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 500, nullable: true, name: 'complete_name' }) + completeName: string | null; + + @Column({ + type: 'enum', + enum: LocationType, + nullable: false, + name: 'location_type', + }) + locationType: LocationType; + + @Column({ type: 'uuid', nullable: true, name: 'parent_id' }) + parentId: string | null; + + @Column({ type: 'boolean', default: false, nullable: false, name: 'is_scrap_location' }) + isScrapLocation: boolean; + + @Column({ type: 'boolean', default: false, nullable: false, name: 'is_return_location' }) + isReturnLocation: boolean; + + @Column({ type: 'boolean', default: true, nullable: false }) + active: boolean; + + // Relations + @ManyToOne(() => Warehouse, (warehouse) => warehouse.locations) + @JoinColumn({ name: 'warehouse_id' }) + warehouse: Warehouse; + + @ManyToOne(() => Location, (location) => location.children) + @JoinColumn({ name: 'parent_id' }) + parent: Location; + + @OneToMany(() => Location, (location) => location.parent) + children: Location[]; + + @OneToMany(() => StockQuant, (stockQuant) => stockQuant.location) + stockQuants: StockQuant[]; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; +} diff --git a/src/modules/inventory/entities/lot.entity.ts b/src/modules/inventory/entities/lot.entity.ts new file mode 100644 index 0000000..aaed4be --- /dev/null +++ b/src/modules/inventory/entities/lot.entity.ts @@ -0,0 +1,64 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { Product } from './product.entity.js'; +import { StockQuant } from './stock-quant.entity.js'; + +@Entity({ schema: 'inventory', name: 'lots' }) +@Index('idx_lots_tenant_id', ['tenantId']) +@Index('idx_lots_product_id', ['productId']) +@Index('idx_lots_name_product', ['productId', 'name'], { unique: true }) +@Index('idx_lots_expiration_date', ['expirationDate']) +export class Lot { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'product_id' }) + productId: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + ref: string | null; + + @Column({ type: 'date', nullable: true, name: 'manufacture_date' }) + manufactureDate: Date | null; + + @Column({ type: 'date', nullable: true, name: 'expiration_date' }) + expirationDate: Date | null; + + @Column({ type: 'date', nullable: true, name: 'removal_date' }) + removalDate: Date | null; + + @Column({ type: 'date', nullable: true, name: 'alert_date' }) + alertDate: Date | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + // Relations + @ManyToOne(() => Product, (product) => product.lots) + @JoinColumn({ name: 'product_id' }) + product: Product; + + @OneToMany(() => StockQuant, (stockQuant) => stockQuant.lot) + stockQuants: StockQuant[]; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; +} diff --git a/src/modules/inventory/entities/picking.entity.ts b/src/modules/inventory/entities/picking.entity.ts new file mode 100644 index 0000000..9254b6a --- /dev/null +++ b/src/modules/inventory/entities/picking.entity.ts @@ -0,0 +1,125 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { Company } from '../../auth/entities/company.entity.js'; +import { Location } from './location.entity.js'; +import { StockMove } from './stock-move.entity.js'; + +export enum PickingType { + INCOMING = 'incoming', + OUTGOING = 'outgoing', + INTERNAL = 'internal', +} + +export enum MoveStatus { + DRAFT = 'draft', + WAITING = 'waiting', + CONFIRMED = 'confirmed', + ASSIGNED = 'assigned', + DONE = 'done', + CANCELLED = 'cancelled', +} + +@Entity({ schema: 'inventory', name: 'pickings' }) +@Index('idx_pickings_tenant_id', ['tenantId']) +@Index('idx_pickings_company_id', ['companyId']) +@Index('idx_pickings_status', ['status']) +@Index('idx_pickings_partner_id', ['partnerId']) +@Index('idx_pickings_scheduled_date', ['scheduledDate']) +export class Picking { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'company_id' }) + companyId: string; + + @Column({ type: 'varchar', length: 100, nullable: false }) + name: string; + + @Column({ + type: 'enum', + enum: PickingType, + nullable: false, + name: 'picking_type', + }) + pickingType: PickingType; + + @Column({ type: 'uuid', nullable: false, name: 'location_id' }) + locationId: string; + + @Column({ type: 'uuid', nullable: false, name: 'location_dest_id' }) + locationDestId: string; + + @Column({ type: 'uuid', nullable: true, name: 'partner_id' }) + partnerId: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'scheduled_date' }) + scheduledDate: Date | null; + + @Column({ type: 'timestamp', nullable: true, name: 'date_done' }) + dateDone: Date | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + origin: string | null; + + @Column({ + type: 'enum', + enum: MoveStatus, + default: MoveStatus.DRAFT, + nullable: false, + }) + status: MoveStatus; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'validated_at' }) + validatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'validated_by' }) + validatedBy: string | null; + + // Relations + @ManyToOne(() => Company) + @JoinColumn({ name: 'company_id' }) + company: Company; + + @ManyToOne(() => Location) + @JoinColumn({ name: 'location_id' }) + location: Location; + + @ManyToOne(() => Location) + @JoinColumn({ name: 'location_dest_id' }) + locationDest: Location; + + @OneToMany(() => StockMove, (stockMove) => stockMove.picking) + moves: StockMove[]; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; +} diff --git a/src/modules/inventory/entities/product.entity.ts b/src/modules/inventory/entities/product.entity.ts new file mode 100644 index 0000000..4a74807 --- /dev/null +++ b/src/modules/inventory/entities/product.entity.ts @@ -0,0 +1,154 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { StockQuant } from './stock-quant.entity.js'; +import { Lot } from './lot.entity.js'; + +export enum ProductType { + STORABLE = 'storable', + CONSUMABLE = 'consumable', + SERVICE = 'service', +} + +export enum TrackingType { + NONE = 'none', + LOT = 'lot', + SERIAL = 'serial', +} + +export enum ValuationMethod { + STANDARD = 'standard', + FIFO = 'fifo', + AVERAGE = 'average', +} + +@Entity({ schema: 'inventory', name: 'products' }) +@Index('idx_products_tenant_id', ['tenantId']) +@Index('idx_products_code', ['code'], { where: 'deleted_at IS NULL' }) +@Index('idx_products_barcode', ['barcode'], { where: 'deleted_at IS NULL' }) +@Index('idx_products_category_id', ['categoryId']) +@Index('idx_products_active', ['active'], { where: 'deleted_at IS NULL' }) +export class Product { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 100, nullable: true, unique: true }) + code: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true }) + barcode: string | null; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ + type: 'enum', + enum: ProductType, + default: ProductType.STORABLE, + nullable: false, + name: 'product_type', + }) + productType: ProductType; + + @Column({ + type: 'enum', + enum: TrackingType, + default: TrackingType.NONE, + nullable: false, + }) + tracking: TrackingType; + + @Column({ type: 'uuid', nullable: true, name: 'category_id' }) + categoryId: string | null; + + @Column({ type: 'uuid', nullable: false, name: 'uom_id' }) + uomId: string; + + @Column({ type: 'uuid', nullable: true, name: 'purchase_uom_id' }) + purchaseUomId: string | null; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0, name: 'cost_price' }) + costPrice: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0, name: 'list_price' }) + listPrice: number; + + @Column({ + type: 'enum', + enum: ValuationMethod, + default: ValuationMethod.FIFO, + nullable: false, + name: 'valuation_method', + }) + valuationMethod: ValuationMethod; + + @Column({ + type: 'boolean', + default: true, + nullable: false, + name: 'is_storable', + generated: 'STORED', + asExpression: "product_type = 'storable'", + }) + isStorable: boolean; + + @Column({ type: 'decimal', precision: 12, scale: 4, nullable: true }) + weight: number | null; + + @Column({ type: 'decimal', precision: 12, scale: 4, nullable: true }) + volume: number | null; + + @Column({ type: 'boolean', default: true, nullable: false, name: 'can_be_sold' }) + canBeSold: boolean; + + @Column({ type: 'boolean', default: true, nullable: false, name: 'can_be_purchased' }) + canBePurchased: boolean; + + @Column({ type: 'varchar', length: 500, nullable: true, name: 'image_url' }) + imageUrl: string | null; + + @Column({ type: 'boolean', default: true, nullable: false }) + active: boolean; + + // Relations + @OneToMany(() => StockQuant, (stockQuant) => stockQuant.product) + stockQuants: StockQuant[]; + + @OneToMany(() => Lot, (lot) => lot.product) + lots: Lot[]; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + deletedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) + deletedBy: string | null; +} diff --git a/src/modules/inventory/entities/stock-move.entity.ts b/src/modules/inventory/entities/stock-move.entity.ts new file mode 100644 index 0000000..c6c8988 --- /dev/null +++ b/src/modules/inventory/entities/stock-move.entity.ts @@ -0,0 +1,104 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Picking, MoveStatus } from './picking.entity.js'; +import { Product } from './product.entity.js'; +import { Location } from './location.entity.js'; +import { Lot } from './lot.entity.js'; + +@Entity({ schema: 'inventory', name: 'stock_moves' }) +@Index('idx_stock_moves_tenant_id', ['tenantId']) +@Index('idx_stock_moves_picking_id', ['pickingId']) +@Index('idx_stock_moves_product_id', ['productId']) +@Index('idx_stock_moves_status', ['status']) +@Index('idx_stock_moves_date', ['date']) +export class StockMove { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'picking_id' }) + pickingId: string; + + @Column({ type: 'uuid', nullable: false, name: 'product_id' }) + productId: string; + + @Column({ type: 'uuid', nullable: false, name: 'product_uom_id' }) + productUomId: string; + + @Column({ type: 'uuid', nullable: false, name: 'location_id' }) + locationId: string; + + @Column({ type: 'uuid', nullable: false, name: 'location_dest_id' }) + locationDestId: string; + + @Column({ type: 'decimal', precision: 16, scale: 4, nullable: false, name: 'product_qty' }) + productQty: number; + + @Column({ type: 'decimal', precision: 16, scale: 4, default: 0, name: 'quantity_done' }) + quantityDone: number; + + @Column({ type: 'uuid', nullable: true, name: 'lot_id' }) + lotId: string | null; + + @Column({ + type: 'enum', + enum: MoveStatus, + default: MoveStatus.DRAFT, + nullable: false, + }) + status: MoveStatus; + + @Column({ type: 'timestamp', nullable: true }) + date: Date | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + origin: string | null; + + // Relations + @ManyToOne(() => Picking, (picking) => picking.moves, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'picking_id' }) + picking: Picking; + + @ManyToOne(() => Product) + @JoinColumn({ name: 'product_id' }) + product: Product; + + @ManyToOne(() => Location) + @JoinColumn({ name: 'location_id' }) + location: Location; + + @ManyToOne(() => Location) + @JoinColumn({ name: 'location_dest_id' }) + locationDest: Location; + + @ManyToOne(() => Lot, { nullable: true }) + @JoinColumn({ name: 'lot_id' }) + lot: Lot | null; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; +} diff --git a/src/modules/inventory/entities/stock-quant.entity.ts b/src/modules/inventory/entities/stock-quant.entity.ts new file mode 100644 index 0000000..3111644 --- /dev/null +++ b/src/modules/inventory/entities/stock-quant.entity.ts @@ -0,0 +1,66 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { Product } from './product.entity.js'; +import { Location } from './location.entity.js'; +import { Lot } from './lot.entity.js'; + +@Entity({ schema: 'inventory', name: 'stock_quants' }) +@Index('idx_stock_quants_product_id', ['productId']) +@Index('idx_stock_quants_location_id', ['locationId']) +@Index('idx_stock_quants_lot_id', ['lotId']) +@Unique('uq_stock_quants_product_location_lot', ['productId', 'locationId', 'lotId']) +export class StockQuant { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'product_id' }) + productId: string; + + @Column({ type: 'uuid', nullable: false, name: 'location_id' }) + locationId: string; + + @Column({ type: 'uuid', nullable: true, name: 'lot_id' }) + lotId: string | null; + + @Column({ type: 'decimal', precision: 16, scale: 4, default: 0 }) + quantity: number; + + @Column({ type: 'decimal', precision: 16, scale: 4, default: 0, name: 'reserved_quantity' }) + reservedQuantity: number; + + // Relations + @ManyToOne(() => Product, (product) => product.stockQuants) + @JoinColumn({ name: 'product_id' }) + product: Product; + + @ManyToOne(() => Location, (location) => location.stockQuants) + @JoinColumn({ name: 'location_id' }) + location: Location; + + @ManyToOne(() => Lot, (lot) => lot.stockQuants, { nullable: true }) + @JoinColumn({ name: 'lot_id' }) + lot: Lot | null; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; +} diff --git a/src/modules/inventory/entities/stock-valuation-layer.entity.ts b/src/modules/inventory/entities/stock-valuation-layer.entity.ts new file mode 100644 index 0000000..25712d0 --- /dev/null +++ b/src/modules/inventory/entities/stock-valuation-layer.entity.ts @@ -0,0 +1,85 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Product } from './product.entity.js'; +import { Company } from '../../auth/entities/company.entity.js'; + +@Entity({ schema: 'inventory', name: 'stock_valuation_layers' }) +@Index('idx_valuation_layers_tenant_id', ['tenantId']) +@Index('idx_valuation_layers_product_id', ['productId']) +@Index('idx_valuation_layers_company_id', ['companyId']) +@Index('idx_valuation_layers_stock_move_id', ['stockMoveId']) +@Index('idx_valuation_layers_remaining_qty', ['remainingQty']) +export class StockValuationLayer { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'product_id' }) + productId: string; + + @Column({ type: 'uuid', nullable: false, name: 'company_id' }) + companyId: string; + + @Column({ type: 'decimal', precision: 16, scale: 4, nullable: false }) + quantity: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: false, name: 'unit_cost' }) + unitCost: number; + + @Column({ type: 'decimal', precision: 16, scale: 2, nullable: false }) + value: number; + + @Column({ type: 'decimal', precision: 16, scale: 4, nullable: false, name: 'remaining_qty' }) + remainingQty: number; + + @Column({ type: 'decimal', precision: 16, scale: 2, nullable: false, name: 'remaining_value' }) + remainingValue: number; + + @Column({ type: 'uuid', nullable: true, name: 'stock_move_id' }) + stockMoveId: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + description: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'account_move_id' }) + accountMoveId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'journal_entry_id' }) + journalEntryId: string | null; + + // Relations + @ManyToOne(() => Product) + @JoinColumn({ name: 'product_id' }) + product: Product; + + @ManyToOne(() => Company) + @JoinColumn({ name: 'company_id' }) + company: Company; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; +} diff --git a/src/modules/inventory/entities/warehouse.entity.ts b/src/modules/inventory/entities/warehouse.entity.ts new file mode 100644 index 0000000..c31af0a --- /dev/null +++ b/src/modules/inventory/entities/warehouse.entity.ts @@ -0,0 +1,68 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +import { Company } from '../../auth/entities/company.entity.js'; +import { Location } from './location.entity.js'; + +@Entity({ schema: 'inventory', name: 'warehouses' }) +@Index('idx_warehouses_tenant_id', ['tenantId']) +@Index('idx_warehouses_company_id', ['companyId']) +@Index('idx_warehouses_code_company', ['companyId', 'code'], { unique: true }) +export class Warehouse { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'uuid', nullable: false, name: 'company_id' }) + companyId: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 20, nullable: false }) + code: string; + + @Column({ type: 'uuid', nullable: true, name: 'address_id' }) + addressId: string | null; + + @Column({ type: 'boolean', default: false, nullable: false, name: 'is_default' }) + isDefault: boolean; + + @Column({ type: 'boolean', default: true, nullable: false }) + active: boolean; + + // Relations + @ManyToOne(() => Company) + @JoinColumn({ name: 'company_id' }) + company: Company; + + @OneToMany(() => Location, (location) => location.warehouse) + locations: Location[]; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; +} diff --git a/src/modules/inventory/index.ts b/src/modules/inventory/index.ts new file mode 100644 index 0000000..40c84f5 --- /dev/null +++ b/src/modules/inventory/index.ts @@ -0,0 +1,16 @@ +export * from './products.service.js'; +export * from './warehouses.service.js'; +export { + locationsService, + Location as InventoryLocation, + CreateLocationDto, + UpdateLocationDto, + LocationFilters +} from './locations.service.js'; +export * from './pickings.service.js'; +export * from './lots.service.js'; +export * from './adjustments.service.js'; +export * from './valuation.service.js'; +export * from './inventory.controller.js'; +export * from './valuation.controller.js'; +export { default as inventoryRoutes } from './inventory.routes.js'; diff --git a/src/modules/inventory/inventory.controller.ts b/src/modules/inventory/inventory.controller.ts new file mode 100644 index 0000000..de2891a --- /dev/null +++ b/src/modules/inventory/inventory.controller.ts @@ -0,0 +1,875 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { productsService, CreateProductDto, UpdateProductDto, ProductFilters } from './products.service.js'; +import { warehousesService, CreateWarehouseDto, UpdateWarehouseDto, WarehouseFilters } from './warehouses.service.js'; +import { locationsService, CreateLocationDto, UpdateLocationDto, LocationFilters } from './locations.service.js'; +import { pickingsService, CreatePickingDto, PickingFilters } from './pickings.service.js'; +import { lotsService, CreateLotDto, UpdateLotDto, LotFilters } from './lots.service.js'; +import { adjustmentsService, CreateAdjustmentDto, UpdateAdjustmentDto, CreateAdjustmentLineDto, UpdateAdjustmentLineDto, AdjustmentFilters } from './adjustments.service.js'; +import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js'; +import { ValidationError } from '../../shared/errors/index.js'; + +// Product schemas +const createProductSchema = z.object({ + name: z.string().min(1, 'El nombre es requerido').max(255), + code: z.string().max(100).optional(), + barcode: z.string().max(100).optional(), + description: z.string().optional(), + product_type: z.enum(['storable', 'consumable', 'service']).default('storable'), + tracking: z.enum(['none', 'lot', 'serial']).default('none'), + category_id: z.string().uuid().optional(), + uom_id: z.string().uuid({ message: 'La unidad de medida es requerida' }), + purchase_uom_id: z.string().uuid().optional(), + cost_price: z.number().min(0).default(0), + list_price: z.number().min(0).default(0), + valuation_method: z.enum(['standard', 'fifo', 'average']).default('fifo'), + weight: z.number().min(0).optional(), + volume: z.number().min(0).optional(), + can_be_sold: z.boolean().default(true), + can_be_purchased: z.boolean().default(true), + image_url: z.string().url().max(500).optional(), +}); + +const updateProductSchema = z.object({ + name: z.string().min(1).max(255).optional(), + barcode: z.string().max(100).optional().nullable(), + description: z.string().optional().nullable(), + tracking: z.enum(['none', 'lot', 'serial']).optional(), + category_id: z.string().uuid().optional().nullable(), + uom_id: z.string().uuid().optional(), + purchase_uom_id: z.string().uuid().optional().nullable(), + cost_price: z.number().min(0).optional(), + list_price: z.number().min(0).optional(), + valuation_method: z.enum(['standard', 'fifo', 'average']).optional(), + weight: z.number().min(0).optional().nullable(), + volume: z.number().min(0).optional().nullable(), + can_be_sold: z.boolean().optional(), + can_be_purchased: z.boolean().optional(), + image_url: z.string().url().max(500).optional().nullable(), + active: z.boolean().optional(), +}); + +const productQuerySchema = z.object({ + search: z.string().optional(), + category_id: z.string().uuid().optional(), + product_type: z.enum(['storable', 'consumable', 'service']).optional(), + can_be_sold: z.coerce.boolean().optional(), + can_be_purchased: z.coerce.boolean().optional(), + active: z.coerce.boolean().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +// Warehouse schemas +const createWarehouseSchema = z.object({ + company_id: z.string().uuid({ message: 'La empresa es requerida' }), + name: z.string().min(1, 'El nombre es requerido').max(255), + code: z.string().min(1).max(20), + address_id: z.string().uuid().optional(), + is_default: z.boolean().default(false), +}); + +const updateWarehouseSchema = z.object({ + name: z.string().min(1).max(255).optional(), + address_id: z.string().uuid().optional().nullable(), + is_default: z.boolean().optional(), + active: z.boolean().optional(), +}); + +const warehouseQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + active: z.coerce.boolean().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(50), +}); + +// Location schemas +const createLocationSchema = z.object({ + warehouse_id: z.string().uuid().optional(), + name: z.string().min(1, 'El nombre es requerido').max(255), + location_type: z.enum(['internal', 'supplier', 'customer', 'inventory', 'production', 'transit']), + parent_id: z.string().uuid().optional(), + is_scrap_location: z.boolean().default(false), + is_return_location: z.boolean().default(false), +}); + +const updateLocationSchema = z.object({ + name: z.string().min(1).max(255).optional(), + parent_id: z.string().uuid().optional().nullable(), + is_scrap_location: z.boolean().optional(), + is_return_location: z.boolean().optional(), + active: z.boolean().optional(), +}); + +const locationQuerySchema = z.object({ + warehouse_id: z.string().uuid().optional(), + location_type: z.enum(['internal', 'supplier', 'customer', 'inventory', 'production', 'transit']).optional(), + active: z.coerce.boolean().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(50), +}); + +// Picking schemas +const stockMoveLineSchema = z.object({ + product_id: z.string().uuid({ message: 'El producto es requerido' }), + product_uom_id: z.string().uuid({ message: 'La UdM es requerida' }), + product_qty: z.number().positive({ message: 'La cantidad debe ser mayor a 0' }), + lot_id: z.string().uuid().optional(), + location_id: z.string().uuid({ message: 'La ubicación origen es requerida' }), + location_dest_id: z.string().uuid({ message: 'La ubicación destino es requerida' }), +}); + +const createPickingSchema = z.object({ + company_id: z.string().uuid({ message: 'La empresa es requerida' }), + name: z.string().min(1, 'El nombre es requerido').max(100), + picking_type: z.enum(['incoming', 'outgoing', 'internal']), + location_id: z.string().uuid({ message: 'La ubicación origen es requerida' }), + location_dest_id: z.string().uuid({ message: 'La ubicación destino es requerida' }), + partner_id: z.string().uuid().optional(), + scheduled_date: z.string().optional(), + origin: z.string().max(255).optional(), + notes: z.string().optional(), + moves: z.array(stockMoveLineSchema).min(1, 'Debe incluir al menos un movimiento'), +}); + +const pickingQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + picking_type: z.enum(['incoming', 'outgoing', 'internal']).optional(), + status: z.enum(['draft', 'waiting', 'confirmed', 'assigned', 'done', 'cancelled']).optional(), + partner_id: z.string().uuid().optional(), + date_from: z.string().optional(), + date_to: z.string().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +// Lot schemas +const createLotSchema = z.object({ + product_id: z.string().uuid({ message: 'El producto es requerido' }), + name: z.string().min(1, 'El nombre del lote es requerido').max(100), + ref: z.string().max(100).optional(), + manufacture_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + expiration_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + removal_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + alert_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + notes: z.string().optional(), +}); + +const updateLotSchema = z.object({ + ref: z.string().max(100).optional().nullable(), + manufacture_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), + expiration_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), + removal_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), + alert_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), + notes: z.string().optional().nullable(), +}); + +const lotQuerySchema = z.object({ + product_id: z.string().uuid().optional(), + expiring_soon: z.coerce.boolean().optional(), + expired: z.coerce.boolean().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(50), +}); + +// Adjustment schemas +const adjustmentLineSchema = z.object({ + product_id: z.string().uuid({ message: 'El producto es requerido' }), + location_id: z.string().uuid({ message: 'La ubicación es requerida' }), + lot_id: z.string().uuid().optional(), + counted_qty: z.number().min(0), + uom_id: z.string().uuid({ message: 'La UdM es requerida' }), + notes: z.string().optional(), +}); + +const createAdjustmentSchema = z.object({ + company_id: z.string().uuid({ message: 'La empresa es requerida' }), + location_id: z.string().uuid({ message: 'La ubicación es requerida' }), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + notes: z.string().optional(), + lines: z.array(adjustmentLineSchema).min(1, 'Debe incluir al menos una línea'), +}); + +const updateAdjustmentSchema = z.object({ + location_id: z.string().uuid().optional(), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + notes: z.string().optional().nullable(), +}); + +const createAdjustmentLineSchema = z.object({ + product_id: z.string().uuid({ message: 'El producto es requerido' }), + location_id: z.string().uuid({ message: 'La ubicación es requerida' }), + lot_id: z.string().uuid().optional(), + counted_qty: z.number().min(0), + uom_id: z.string().uuid({ message: 'La UdM es requerida' }), + notes: z.string().optional(), +}); + +const updateAdjustmentLineSchema = z.object({ + counted_qty: z.number().min(0).optional(), + notes: z.string().optional().nullable(), +}); + +const adjustmentQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + location_id: z.string().uuid().optional(), + status: z.enum(['draft', 'confirmed', 'done', 'cancelled']).optional(), + date_from: z.string().optional(), + date_to: z.string().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +class InventoryController { + // ========== PRODUCTS ========== + async getProducts(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = productQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: ProductFilters = queryResult.data; + const result = await productsService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + async getProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const product = await productsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: product }); + } catch (error) { + next(error); + } + } + + async createProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createProductSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de producto inválidos', parseResult.error.errors); + } + + const dto: CreateProductDto = parseResult.data; + const product = await productsService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: product, + message: 'Producto creado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateProductSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de producto inválidos', parseResult.error.errors); + } + + const dto: UpdateProductDto = parseResult.data; + const product = await productsService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: product, + message: 'Producto actualizado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async deleteProduct(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await productsService.delete(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, message: 'Producto eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + async getProductStock(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const stock = await productsService.getStock(req.params.id, req.tenantId!); + res.json({ success: true, data: stock }); + } catch (error) { + next(error); + } + } + + // ========== WAREHOUSES ========== + async getWarehouses(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = warehouseQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: WarehouseFilters = queryResult.data; + const result = await warehousesService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 50)), + }, + }); + } catch (error) { + next(error); + } + } + + async getWarehouse(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const warehouse = await warehousesService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: warehouse }); + } catch (error) { + next(error); + } + } + + async createWarehouse(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createWarehouseSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de almacén inválidos', parseResult.error.errors); + } + + const dto: CreateWarehouseDto = parseResult.data; + const warehouse = await warehousesService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: warehouse, + message: 'Almacén creado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateWarehouse(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateWarehouseSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de almacén inválidos', parseResult.error.errors); + } + + const dto: UpdateWarehouseDto = parseResult.data; + const warehouse = await warehousesService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: warehouse, + message: 'Almacén actualizado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async deleteWarehouse(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await warehousesService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Almacén eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + async getWarehouseLocations(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const locations = await warehousesService.getLocations(req.params.id, req.tenantId!); + res.json({ success: true, data: locations }); + } catch (error) { + next(error); + } + } + + async getWarehouseStock(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const stock = await warehousesService.getStock(req.params.id, req.tenantId!); + res.json({ success: true, data: stock }); + } catch (error) { + next(error); + } + } + + // ========== LOCATIONS ========== + async getLocations(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = locationQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: LocationFilters = queryResult.data; + const result = await locationsService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 50)), + }, + }); + } catch (error) { + next(error); + } + } + + async getLocation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const location = await locationsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: location }); + } catch (error) { + next(error); + } + } + + async createLocation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createLocationSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de ubicación inválidos', parseResult.error.errors); + } + + const dto: CreateLocationDto = parseResult.data; + const location = await locationsService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: location, + message: 'Ubicación creada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateLocation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateLocationSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de ubicación inválidos', parseResult.error.errors); + } + + const dto: UpdateLocationDto = parseResult.data; + const location = await locationsService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: location, + message: 'Ubicación actualizada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async getLocationStock(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const stock = await locationsService.getStock(req.params.id, req.tenantId!); + res.json({ success: true, data: stock }); + } catch (error) { + next(error); + } + } + + // ========== PICKINGS ========== + async getPickings(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = pickingQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: PickingFilters = queryResult.data; + const result = await pickingsService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + async getPicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const picking = await pickingsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: picking }); + } catch (error) { + next(error); + } + } + + async createPicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createPickingSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de picking inválidos', parseResult.error.errors); + } + + const dto: CreatePickingDto = parseResult.data; + const picking = await pickingsService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: picking, + message: 'Picking creado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async confirmPicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const picking = await pickingsService.confirm(req.params.id, req.tenantId!, req.user!.userId); + res.json({ + success: true, + data: picking, + message: 'Picking confirmado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async validatePicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const picking = await pickingsService.validate(req.params.id, req.tenantId!, req.user!.userId); + res.json({ + success: true, + data: picking, + message: 'Picking validado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async cancelPicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const picking = await pickingsService.cancel(req.params.id, req.tenantId!, req.user!.userId); + res.json({ + success: true, + data: picking, + message: 'Picking cancelado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async deletePicking(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await pickingsService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Picking eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== LOTS ========== + async getLots(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = lotQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: LotFilters = queryResult.data; + const result = await lotsService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 50)), + }, + }); + } catch (error) { + next(error); + } + } + + async getLot(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const lot = await lotsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: lot }); + } catch (error) { + next(error); + } + } + + async createLot(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createLotSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de lote inválidos', parseResult.error.errors); + } + + const dto: CreateLotDto = parseResult.data; + const lot = await lotsService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: lot, + message: 'Lote creado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateLot(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateLotSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de lote inválidos', parseResult.error.errors); + } + + const dto: UpdateLotDto = parseResult.data; + const lot = await lotsService.update(req.params.id, dto, req.tenantId!); + + res.json({ + success: true, + data: lot, + message: 'Lote actualizado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async getLotMovements(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const movements = await lotsService.getMovements(req.params.id, req.tenantId!); + res.json({ success: true, data: movements }); + } catch (error) { + next(error); + } + } + + async deleteLot(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await lotsService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Lote eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== ADJUSTMENTS ========== + async getAdjustments(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = adjustmentQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: AdjustmentFilters = queryResult.data; + const result = await adjustmentsService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + async getAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const adjustment = await adjustmentsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: adjustment }); + } catch (error) { + next(error); + } + } + + async createAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createAdjustmentSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de ajuste inválidos', parseResult.error.errors); + } + + const dto: CreateAdjustmentDto = parseResult.data; + const adjustment = await adjustmentsService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: adjustment, + message: 'Ajuste de inventario creado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateAdjustmentSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de ajuste inválidos', parseResult.error.errors); + } + + const dto: UpdateAdjustmentDto = parseResult.data; + const adjustment = await adjustmentsService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: adjustment, + message: 'Ajuste de inventario actualizado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async addAdjustmentLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createAdjustmentLineSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de línea inválidos', parseResult.error.errors); + } + + const dto: CreateAdjustmentLineDto = parseResult.data; + const line = await adjustmentsService.addLine(req.params.id, dto, req.tenantId!); + + res.status(201).json({ + success: true, + data: line, + message: 'Línea agregada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateAdjustmentLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateAdjustmentLineSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de línea inválidos', parseResult.error.errors); + } + + const dto: UpdateAdjustmentLineDto = parseResult.data; + const line = await adjustmentsService.updateLine(req.params.id, req.params.lineId, dto, req.tenantId!); + + res.json({ + success: true, + data: line, + message: 'Línea actualizada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async removeAdjustmentLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await adjustmentsService.removeLine(req.params.id, req.params.lineId, req.tenantId!); + res.json({ success: true, message: 'Línea eliminada exitosamente' }); + } catch (error) { + next(error); + } + } + + async confirmAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const adjustment = await adjustmentsService.confirm(req.params.id, req.tenantId!, req.user!.userId); + res.json({ + success: true, + data: adjustment, + message: 'Ajuste confirmado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async validateAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const adjustment = await adjustmentsService.validate(req.params.id, req.tenantId!, req.user!.userId); + res.json({ + success: true, + data: adjustment, + message: 'Ajuste validado exitosamente. Stock actualizado.', + }); + } catch (error) { + next(error); + } + } + + async cancelAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const adjustment = await adjustmentsService.cancel(req.params.id, req.tenantId!, req.user!.userId); + res.json({ + success: true, + data: adjustment, + message: 'Ajuste cancelado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async deleteAdjustment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await adjustmentsService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Ajuste eliminado exitosamente' }); + } catch (error) { + next(error); + } + } +} + +export const inventoryController = new InventoryController(); diff --git a/src/modules/inventory/inventory.routes.ts b/src/modules/inventory/inventory.routes.ts new file mode 100644 index 0000000..6f45bf6 --- /dev/null +++ b/src/modules/inventory/inventory.routes.ts @@ -0,0 +1,174 @@ +import { Router } from 'express'; +import { inventoryController } from './inventory.controller.js'; +import { valuationController } from './valuation.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// ========== PRODUCTS ========== +router.get('/products', (req, res, next) => inventoryController.getProducts(req, res, next)); + +router.get('/products/:id', (req, res, next) => inventoryController.getProduct(req, res, next)); + +router.get('/products/:id/stock', (req, res, next) => inventoryController.getProductStock(req, res, next)); + +router.post('/products', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + inventoryController.createProduct(req, res, next) +); + +router.put('/products/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + inventoryController.updateProduct(req, res, next) +); + +router.delete('/products/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + inventoryController.deleteProduct(req, res, next) +); + +// ========== WAREHOUSES ========== +router.get('/warehouses', (req, res, next) => inventoryController.getWarehouses(req, res, next)); + +router.get('/warehouses/:id', (req, res, next) => inventoryController.getWarehouse(req, res, next)); + +router.get('/warehouses/:id/locations', (req, res, next) => inventoryController.getWarehouseLocations(req, res, next)); + +router.get('/warehouses/:id/stock', (req, res, next) => inventoryController.getWarehouseStock(req, res, next)); + +router.post('/warehouses', requireRoles('admin', 'super_admin'), (req, res, next) => + inventoryController.createWarehouse(req, res, next) +); + +router.put('/warehouses/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + inventoryController.updateWarehouse(req, res, next) +); + +router.delete('/warehouses/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + inventoryController.deleteWarehouse(req, res, next) +); + +// ========== LOCATIONS ========== +router.get('/locations', (req, res, next) => inventoryController.getLocations(req, res, next)); + +router.get('/locations/:id', (req, res, next) => inventoryController.getLocation(req, res, next)); + +router.get('/locations/:id/stock', (req, res, next) => inventoryController.getLocationStock(req, res, next)); + +router.post('/locations', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + inventoryController.createLocation(req, res, next) +); + +router.put('/locations/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + inventoryController.updateLocation(req, res, next) +); + +// ========== PICKINGS ========== +router.get('/pickings', (req, res, next) => inventoryController.getPickings(req, res, next)); + +router.get('/pickings/:id', (req, res, next) => inventoryController.getPicking(req, res, next)); + +router.post('/pickings', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + inventoryController.createPicking(req, res, next) +); + +router.post('/pickings/:id/confirm', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + inventoryController.confirmPicking(req, res, next) +); + +router.post('/pickings/:id/validate', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + inventoryController.validatePicking(req, res, next) +); + +router.post('/pickings/:id/cancel', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + inventoryController.cancelPicking(req, res, next) +); + +router.delete('/pickings/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + inventoryController.deletePicking(req, res, next) +); + +// ========== LOTS ========== +router.get('/lots', (req, res, next) => inventoryController.getLots(req, res, next)); + +router.get('/lots/:id', (req, res, next) => inventoryController.getLot(req, res, next)); + +router.get('/lots/:id/movements', (req, res, next) => inventoryController.getLotMovements(req, res, next)); + +router.post('/lots', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + inventoryController.createLot(req, res, next) +); + +router.put('/lots/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + inventoryController.updateLot(req, res, next) +); + +router.delete('/lots/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + inventoryController.deleteLot(req, res, next) +); + +// ========== ADJUSTMENTS ========== +router.get('/adjustments', (req, res, next) => inventoryController.getAdjustments(req, res, next)); + +router.get('/adjustments/:id', (req, res, next) => inventoryController.getAdjustment(req, res, next)); + +router.post('/adjustments', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + inventoryController.createAdjustment(req, res, next) +); + +router.put('/adjustments/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + inventoryController.updateAdjustment(req, res, next) +); + +// Adjustment lines +router.post('/adjustments/:id/lines', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + inventoryController.addAdjustmentLine(req, res, next) +); + +router.put('/adjustments/:id/lines/:lineId', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + inventoryController.updateAdjustmentLine(req, res, next) +); + +router.delete('/adjustments/:id/lines/:lineId', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + inventoryController.removeAdjustmentLine(req, res, next) +); + +// Adjustment workflow +router.post('/adjustments/:id/confirm', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + inventoryController.confirmAdjustment(req, res, next) +); + +router.post('/adjustments/:id/validate', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + inventoryController.validateAdjustment(req, res, next) +); + +router.post('/adjustments/:id/cancel', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + inventoryController.cancelAdjustment(req, res, next) +); + +router.delete('/adjustments/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + inventoryController.deleteAdjustment(req, res, next) +); + +// ========== VALUATION ========== +router.get('/valuation/cost', (req, res, next) => valuationController.getProductCost(req, res, next)); + +router.get('/valuation/report', (req, res, next) => valuationController.getCompanyReport(req, res, next)); + +router.get('/valuation/products/:productId/summary', (req, res, next) => + valuationController.getProductSummary(req, res, next) +); + +router.get('/valuation/products/:productId/layers', (req, res, next) => + valuationController.getProductLayers(req, res, next) +); + +router.post('/valuation/layers', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + valuationController.createLayer(req, res, next) +); + +router.post('/valuation/consume', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + valuationController.consumeFifo(req, res, next) +); + +export default router; diff --git a/src/modules/inventory/locations.service.ts b/src/modules/inventory/locations.service.ts new file mode 100644 index 0000000..c55aba4 --- /dev/null +++ b/src/modules/inventory/locations.service.ts @@ -0,0 +1,212 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ConflictError } from '../../shared/errors/index.js'; + +export type LocationType = 'internal' | 'supplier' | 'customer' | 'inventory' | 'production' | 'transit'; + +export interface Location { + id: string; + tenant_id: string; + warehouse_id?: string; + warehouse_name?: string; + name: string; + complete_name?: string; + location_type: LocationType; + parent_id?: string; + parent_name?: string; + is_scrap_location: boolean; + is_return_location: boolean; + active: boolean; + created_at: Date; +} + +export interface CreateLocationDto { + warehouse_id?: string; + name: string; + location_type: LocationType; + parent_id?: string; + is_scrap_location?: boolean; + is_return_location?: boolean; +} + +export interface UpdateLocationDto { + name?: string; + parent_id?: string | null; + is_scrap_location?: boolean; + is_return_location?: boolean; + active?: boolean; +} + +export interface LocationFilters { + warehouse_id?: string; + location_type?: LocationType; + active?: boolean; + page?: number; + limit?: number; +} + +class LocationsService { + async findAll(tenantId: string, filters: LocationFilters = {}): Promise<{ data: Location[]; total: number }> { + const { warehouse_id, location_type, active, page = 1, limit = 50 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE l.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (warehouse_id) { + whereClause += ` AND l.warehouse_id = $${paramIndex++}`; + params.push(warehouse_id); + } + + if (location_type) { + whereClause += ` AND l.location_type = $${paramIndex++}`; + params.push(location_type); + } + + if (active !== undefined) { + whereClause += ` AND l.active = $${paramIndex++}`; + params.push(active); + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM inventory.locations l ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT l.*, + w.name as warehouse_name, + lp.name as parent_name + FROM inventory.locations l + LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id + LEFT JOIN inventory.locations lp ON l.parent_id = lp.id + ${whereClause} + ORDER BY l.complete_name + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const location = await queryOne( + `SELECT l.*, + w.name as warehouse_name, + lp.name as parent_name + FROM inventory.locations l + LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id + LEFT JOIN inventory.locations lp ON l.parent_id = lp.id + WHERE l.id = $1 AND l.tenant_id = $2`, + [id, tenantId] + ); + + if (!location) { + throw new NotFoundError('Ubicación no encontrada'); + } + + return location; + } + + async create(dto: CreateLocationDto, tenantId: string, userId: string): Promise { + // Validate parent location if specified + if (dto.parent_id) { + const parent = await queryOne( + `SELECT id FROM inventory.locations WHERE id = $1 AND tenant_id = $2`, + [dto.parent_id, tenantId] + ); + if (!parent) { + throw new NotFoundError('Ubicación padre no encontrada'); + } + } + + const location = await queryOne( + `INSERT INTO inventory.locations (tenant_id, warehouse_id, name, location_type, parent_id, is_scrap_location, is_return_location, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [ + tenantId, + dto.warehouse_id, + dto.name, + dto.location_type, + dto.parent_id, + dto.is_scrap_location || false, + dto.is_return_location || false, + userId, + ] + ); + + return location!; + } + + async update(id: string, dto: UpdateLocationDto, tenantId: string, userId: string): Promise { + await this.findById(id, tenantId); + + // Validate parent (prevent self-reference) + if (dto.parent_id) { + if (dto.parent_id === id) { + throw new ConflictError('Una ubicación no puede ser su propia ubicación padre'); + } + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.name !== undefined) { + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.parent_id !== undefined) { + updateFields.push(`parent_id = $${paramIndex++}`); + values.push(dto.parent_id); + } + if (dto.is_scrap_location !== undefined) { + updateFields.push(`is_scrap_location = $${paramIndex++}`); + values.push(dto.is_scrap_location); + } + if (dto.is_return_location !== undefined) { + updateFields.push(`is_return_location = $${paramIndex++}`); + values.push(dto.is_return_location); + } + if (dto.active !== undefined) { + updateFields.push(`active = $${paramIndex++}`); + values.push(dto.active); + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + const location = await queryOne( + `UPDATE inventory.locations SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} + RETURNING *`, + values + ); + + return location!; + } + + async getStock(locationId: string, tenantId: string): Promise { + await this.findById(locationId, tenantId); + + return query( + `SELECT sq.*, p.name as product_name, p.code as product_code, u.name as uom_name + FROM inventory.stock_quants sq + INNER JOIN inventory.products p ON sq.product_id = p.id + LEFT JOIN core.uom u ON p.uom_id = u.id + WHERE sq.location_id = $1 AND sq.quantity > 0 + ORDER BY p.name`, + [locationId] + ); + } +} + +export const locationsService = new LocationsService(); diff --git a/src/modules/inventory/lots.service.ts b/src/modules/inventory/lots.service.ts new file mode 100644 index 0000000..2a9d5e8 --- /dev/null +++ b/src/modules/inventory/lots.service.ts @@ -0,0 +1,263 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ConflictError } from '../../shared/errors/index.js'; + +export interface Lot { + id: string; + tenant_id: string; + product_id: string; + product_name?: string; + product_code?: string; + name: string; + ref?: string; + manufacture_date?: Date; + expiration_date?: Date; + removal_date?: Date; + alert_date?: Date; + notes?: string; + created_at: Date; + quantity_on_hand?: number; +} + +export interface CreateLotDto { + product_id: string; + name: string; + ref?: string; + manufacture_date?: string; + expiration_date?: string; + removal_date?: string; + alert_date?: string; + notes?: string; +} + +export interface UpdateLotDto { + ref?: string | null; + manufacture_date?: string | null; + expiration_date?: string | null; + removal_date?: string | null; + alert_date?: string | null; + notes?: string | null; +} + +export interface LotFilters { + product_id?: string; + expiring_soon?: boolean; + expired?: boolean; + search?: string; + page?: number; + limit?: number; +} + +export interface LotMovement { + id: string; + date: Date; + origin: string; + location_from: string; + location_to: string; + quantity: number; + status: string; +} + +class LotsService { + async findAll(tenantId: string, filters: LotFilters = {}): Promise<{ data: Lot[]; total: number }> { + const { product_id, expiring_soon, expired, search, page = 1, limit = 50 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE l.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (product_id) { + whereClause += ` AND l.product_id = $${paramIndex++}`; + params.push(product_id); + } + + if (expiring_soon) { + whereClause += ` AND l.expiration_date IS NOT NULL AND l.expiration_date <= CURRENT_DATE + INTERVAL '30 days' AND l.expiration_date > CURRENT_DATE`; + } + + if (expired) { + whereClause += ` AND l.expiration_date IS NOT NULL AND l.expiration_date < CURRENT_DATE`; + } + + if (search) { + whereClause += ` AND (l.name ILIKE $${paramIndex} OR l.ref ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count + FROM inventory.lots l + LEFT JOIN inventory.products p ON l.product_id = p.id + ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT l.*, + p.name as product_name, + p.code as product_code, + COALESCE(sq.total_qty, 0) as quantity_on_hand + FROM inventory.lots l + LEFT JOIN inventory.products p ON l.product_id = p.id + LEFT JOIN ( + SELECT lot_id, SUM(quantity) as total_qty + FROM inventory.stock_quants + GROUP BY lot_id + ) sq ON l.id = sq.lot_id + ${whereClause} + ORDER BY l.expiration_date ASC NULLS LAST, l.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const lot = await queryOne( + `SELECT l.*, + p.name as product_name, + p.code as product_code, + COALESCE(sq.total_qty, 0) as quantity_on_hand + FROM inventory.lots l + LEFT JOIN inventory.products p ON l.product_id = p.id + LEFT JOIN ( + SELECT lot_id, SUM(quantity) as total_qty + FROM inventory.stock_quants + GROUP BY lot_id + ) sq ON l.id = sq.lot_id + WHERE l.id = $1 AND l.tenant_id = $2`, + [id, tenantId] + ); + + if (!lot) { + throw new NotFoundError('Lote no encontrado'); + } + + return lot; + } + + async create(dto: CreateLotDto, tenantId: string, userId: string): Promise { + // Check for unique lot name for product + const existing = await queryOne( + `SELECT id FROM inventory.lots WHERE product_id = $1 AND name = $2`, + [dto.product_id, dto.name] + ); + + if (existing) { + throw new ConflictError('Ya existe un lote con ese nombre para este producto'); + } + + const lot = await queryOne( + `INSERT INTO inventory.lots ( + tenant_id, product_id, name, ref, manufacture_date, expiration_date, + removal_date, alert_date, notes, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [ + tenantId, dto.product_id, dto.name, dto.ref, dto.manufacture_date, + dto.expiration_date, dto.removal_date, dto.alert_date, dto.notes, userId + ] + ); + + return this.findById(lot!.id, tenantId); + } + + async update(id: string, dto: UpdateLotDto, tenantId: string): Promise { + await this.findById(id, tenantId); + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.ref !== undefined) { + updateFields.push(`ref = $${paramIndex++}`); + values.push(dto.ref); + } + if (dto.manufacture_date !== undefined) { + updateFields.push(`manufacture_date = $${paramIndex++}`); + values.push(dto.manufacture_date); + } + if (dto.expiration_date !== undefined) { + updateFields.push(`expiration_date = $${paramIndex++}`); + values.push(dto.expiration_date); + } + if (dto.removal_date !== undefined) { + updateFields.push(`removal_date = $${paramIndex++}`); + values.push(dto.removal_date); + } + if (dto.alert_date !== undefined) { + updateFields.push(`alert_date = $${paramIndex++}`); + values.push(dto.alert_date); + } + if (dto.notes !== undefined) { + updateFields.push(`notes = $${paramIndex++}`); + values.push(dto.notes); + } + + if (updateFields.length === 0) { + return this.findById(id, tenantId); + } + + values.push(id, tenantId); + + await query( + `UPDATE inventory.lots SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async getMovements(id: string, tenantId: string): Promise { + await this.findById(id, tenantId); + + const movements = await query( + `SELECT sm.id, + sm.date, + sm.origin, + lo.name as location_from, + ld.name as location_to, + sm.quantity_done as quantity, + sm.status + FROM inventory.stock_moves sm + LEFT JOIN inventory.locations lo ON sm.location_id = lo.id + LEFT JOIN inventory.locations ld ON sm.location_dest_id = ld.id + WHERE sm.lot_id = $1 AND sm.status = 'done' + ORDER BY sm.date DESC`, + [id] + ); + + return movements; + } + + async delete(id: string, tenantId: string): Promise { + const lot = await this.findById(id, tenantId); + + // Check if lot has stock + if (lot.quantity_on_hand && lot.quantity_on_hand > 0) { + throw new ConflictError('No se puede eliminar un lote con stock'); + } + + // Check if lot is used in moves + const movesCheck = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM inventory.stock_moves WHERE lot_id = $1`, + [id] + ); + + if (parseInt(movesCheck?.count || '0') > 0) { + throw new ConflictError('No se puede eliminar: el lote tiene movimientos asociados'); + } + + await query(`DELETE FROM inventory.lots WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } +} + +export const lotsService = new LotsService(); diff --git a/src/modules/inventory/pickings.service.ts b/src/modules/inventory/pickings.service.ts new file mode 100644 index 0000000..6c66c18 --- /dev/null +++ b/src/modules/inventory/pickings.service.ts @@ -0,0 +1,357 @@ +import { query, queryOne, getClient } from '../../config/database.js'; +import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js'; + +export type PickingType = 'incoming' | 'outgoing' | 'internal'; +export type MoveStatus = 'draft' | 'waiting' | 'confirmed' | 'assigned' | 'done' | 'cancelled'; + +export interface StockMoveLine { + id?: string; + product_id: string; + product_name?: string; + product_code?: string; + product_uom_id: string; + uom_name?: string; + product_qty: number; + quantity_done?: number; + lot_id?: string; + location_id: string; + location_name?: string; + location_dest_id: string; + location_dest_name?: string; + status?: MoveStatus; +} + +export interface Picking { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + name: string; + picking_type: PickingType; + location_id: string; + location_name?: string; + location_dest_id: string; + location_dest_name?: string; + partner_id?: string; + partner_name?: string; + scheduled_date?: Date; + date_done?: Date; + origin?: string; + status: MoveStatus; + notes?: string; + moves?: StockMoveLine[]; + created_at: Date; + validated_at?: Date; +} + +export interface CreatePickingDto { + company_id: string; + name: string; + picking_type: PickingType; + location_id: string; + location_dest_id: string; + partner_id?: string; + scheduled_date?: string; + origin?: string; + notes?: string; + moves: Omit[]; +} + +export interface UpdatePickingDto { + partner_id?: string | null; + scheduled_date?: string | null; + origin?: string | null; + notes?: string | null; + moves?: Omit[]; +} + +export interface PickingFilters { + company_id?: string; + picking_type?: PickingType; + status?: MoveStatus; + partner_id?: string; + date_from?: string; + date_to?: string; + search?: string; + page?: number; + limit?: number; +} + +class PickingsService { + async findAll(tenantId: string, filters: PickingFilters = {}): Promise<{ data: Picking[]; total: number }> { + const { company_id, picking_type, status, partner_id, date_from, date_to, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE p.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND p.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (picking_type) { + whereClause += ` AND p.picking_type = $${paramIndex++}`; + params.push(picking_type); + } + + if (status) { + whereClause += ` AND p.status = $${paramIndex++}`; + params.push(status); + } + + if (partner_id) { + whereClause += ` AND p.partner_id = $${paramIndex++}`; + params.push(partner_id); + } + + if (date_from) { + whereClause += ` AND p.scheduled_date >= $${paramIndex++}`; + params.push(date_from); + } + + if (date_to) { + whereClause += ` AND p.scheduled_date <= $${paramIndex++}`; + params.push(date_to); + } + + if (search) { + whereClause += ` AND (p.name ILIKE $${paramIndex} OR p.origin ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM inventory.pickings p ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT p.*, + c.name as company_name, + l.name as location_name, + ld.name as location_dest_name, + pa.name as partner_name + FROM inventory.pickings p + LEFT JOIN auth.companies c ON p.company_id = c.id + LEFT JOIN inventory.locations l ON p.location_id = l.id + LEFT JOIN inventory.locations ld ON p.location_dest_id = ld.id + LEFT JOIN core.partners pa ON p.partner_id = pa.id + ${whereClause} + ORDER BY p.scheduled_date DESC NULLS LAST, p.name DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const picking = await queryOne( + `SELECT p.*, + c.name as company_name, + l.name as location_name, + ld.name as location_dest_name, + pa.name as partner_name + FROM inventory.pickings p + LEFT JOIN auth.companies c ON p.company_id = c.id + LEFT JOIN inventory.locations l ON p.location_id = l.id + LEFT JOIN inventory.locations ld ON p.location_dest_id = ld.id + LEFT JOIN core.partners pa ON p.partner_id = pa.id + WHERE p.id = $1 AND p.tenant_id = $2`, + [id, tenantId] + ); + + if (!picking) { + throw new NotFoundError('Picking no encontrado'); + } + + // Get moves + const moves = await query( + `SELECT sm.*, + pr.name as product_name, + pr.code as product_code, + u.name as uom_name, + l.name as location_name, + ld.name as location_dest_name + FROM inventory.stock_moves sm + LEFT JOIN inventory.products pr ON sm.product_id = pr.id + LEFT JOIN core.uom u ON sm.product_uom_id = u.id + LEFT JOIN inventory.locations l ON sm.location_id = l.id + LEFT JOIN inventory.locations ld ON sm.location_dest_id = ld.id + WHERE sm.picking_id = $1 + ORDER BY sm.created_at`, + [id] + ); + + picking.moves = moves; + + return picking; + } + + async create(dto: CreatePickingDto, tenantId: string, userId: string): Promise { + if (dto.moves.length === 0) { + throw new ValidationError('El picking debe tener al menos un movimiento'); + } + + const client = await getClient(); + + try { + await client.query('BEGIN'); + + // Create picking + const pickingResult = await client.query( + `INSERT INTO inventory.pickings (tenant_id, company_id, name, picking_type, location_id, location_dest_id, partner_id, scheduled_date, origin, notes, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *`, + [tenantId, dto.company_id, dto.name, dto.picking_type, dto.location_id, dto.location_dest_id, dto.partner_id, dto.scheduled_date, dto.origin, dto.notes, userId] + ); + const picking = pickingResult.rows[0] as Picking; + + // Create moves + for (const move of dto.moves) { + await client.query( + `INSERT INTO inventory.stock_moves (tenant_id, picking_id, product_id, product_uom_id, location_id, location_dest_id, product_qty, lot_id, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [tenantId, picking.id, move.product_id, move.product_uom_id, move.location_id, move.location_dest_id, move.product_qty, move.lot_id, userId] + ); + } + + await client.query('COMMIT'); + + return this.findById(picking.id, tenantId); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async confirm(id: string, tenantId: string, userId: string): Promise { + const picking = await this.findById(id, tenantId); + + if (picking.status !== 'draft') { + throw new ConflictError('Solo se pueden confirmar pickings en estado borrador'); + } + + await query( + `UPDATE inventory.pickings SET status = 'confirmed', updated_at = CURRENT_TIMESTAMP, updated_by = $1 WHERE id = $2`, + [userId, id] + ); + + await query( + `UPDATE inventory.stock_moves SET status = 'confirmed', updated_at = CURRENT_TIMESTAMP, updated_by = $1 WHERE picking_id = $2`, + [userId, id] + ); + + return this.findById(id, tenantId); + } + + async validate(id: string, tenantId: string, userId: string): Promise { + const picking = await this.findById(id, tenantId); + + if (picking.status === 'done') { + throw new ConflictError('El picking ya está validado'); + } + + if (picking.status === 'cancelled') { + throw new ConflictError('No se puede validar un picking cancelado'); + } + + const client = await getClient(); + + try { + await client.query('BEGIN'); + + // Update stock quants for each move + for (const move of picking.moves || []) { + const qty = move.product_qty; + + // Decrease from source location + await client.query( + `INSERT INTO inventory.stock_quants (product_id, location_id, quantity) + VALUES ($1, $2, -$3) + ON CONFLICT (product_id, location_id, COALESCE(lot_id, '00000000-0000-0000-0000-000000000000')) + DO UPDATE SET quantity = stock_quants.quantity - $3, updated_at = CURRENT_TIMESTAMP`, + [move.product_id, move.location_id, qty] + ); + + // Increase in destination location + await client.query( + `INSERT INTO inventory.stock_quants (product_id, location_id, quantity) + VALUES ($1, $2, $3) + ON CONFLICT (product_id, location_id, COALESCE(lot_id, '00000000-0000-0000-0000-000000000000')) + DO UPDATE SET quantity = stock_quants.quantity + $3, updated_at = CURRENT_TIMESTAMP`, + [move.product_id, move.location_dest_id, qty] + ); + + // Update move + await client.query( + `UPDATE inventory.stock_moves + SET quantity_done = $1, status = 'done', date = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP, updated_by = $2 + WHERE id = $3`, + [qty, userId, move.id] + ); + } + + // Update picking + await client.query( + `UPDATE inventory.pickings + SET status = 'done', date_done = CURRENT_TIMESTAMP, validated_at = CURRENT_TIMESTAMP, validated_by = $1, updated_at = CURRENT_TIMESTAMP, updated_by = $1 + WHERE id = $2`, + [userId, id] + ); + + await client.query('COMMIT'); + + return this.findById(id, tenantId); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async cancel(id: string, tenantId: string, userId: string): Promise { + const picking = await this.findById(id, tenantId); + + if (picking.status === 'done') { + throw new ConflictError('No se puede cancelar un picking ya validado'); + } + + if (picking.status === 'cancelled') { + throw new ConflictError('El picking ya está cancelado'); + } + + await query( + `UPDATE inventory.pickings SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP, updated_by = $1 WHERE id = $2`, + [userId, id] + ); + + await query( + `UPDATE inventory.stock_moves SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP, updated_by = $1 WHERE picking_id = $2`, + [userId, id] + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + const picking = await this.findById(id, tenantId); + + if (picking.status !== 'draft') { + throw new ConflictError('Solo se pueden eliminar pickings en estado borrador'); + } + + await query(`DELETE FROM inventory.pickings WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } +} + +export const pickingsService = new PickingsService(); diff --git a/src/modules/inventory/products.service.ts b/src/modules/inventory/products.service.ts new file mode 100644 index 0000000..29334c3 --- /dev/null +++ b/src/modules/inventory/products.service.ts @@ -0,0 +1,410 @@ +import { Repository, IsNull, ILike } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { Product, ProductType, TrackingType, ValuationMethod } from './entities/product.entity.js'; +import { StockQuant } from './entities/stock-quant.entity.js'; +import { NotFoundError, ValidationError, ConflictError } from '../../shared/types/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ===== Interfaces ===== + +export interface CreateProductDto { + name: string; + code?: string; + barcode?: string; + description?: string; + productType?: ProductType; + tracking?: TrackingType; + categoryId?: string; + uomId: string; + purchaseUomId?: string; + costPrice?: number; + listPrice?: number; + valuationMethod?: ValuationMethod; + weight?: number; + volume?: number; + canBeSold?: boolean; + canBePurchased?: boolean; + imageUrl?: string; +} + +export interface UpdateProductDto { + name?: string; + barcode?: string | null; + description?: string | null; + tracking?: TrackingType; + categoryId?: string | null; + uomId?: string; + purchaseUomId?: string | null; + costPrice?: number; + listPrice?: number; + valuationMethod?: ValuationMethod; + weight?: number | null; + volume?: number | null; + canBeSold?: boolean; + canBePurchased?: boolean; + imageUrl?: string | null; + active?: boolean; +} + +export interface ProductFilters { + search?: string; + categoryId?: string; + productType?: ProductType; + canBeSold?: boolean; + canBePurchased?: boolean; + active?: boolean; + page?: number; + limit?: number; +} + +export interface ProductWithRelations extends Product { + categoryName?: string; + uomName?: string; + purchaseUomName?: string; +} + +// ===== Service Class ===== + +class ProductsService { + private productRepository: Repository; + private stockQuantRepository: Repository; + + constructor() { + this.productRepository = AppDataSource.getRepository(Product); + this.stockQuantRepository = AppDataSource.getRepository(StockQuant); + } + + /** + * Get all products with filters and pagination + */ + async findAll( + tenantId: string, + filters: ProductFilters = {} + ): Promise<{ data: ProductWithRelations[]; total: number }> { + try { + const { search, categoryId, productType, canBeSold, canBePurchased, active, page = 1, limit = 20 } = filters; + const skip = (page - 1) * limit; + + const queryBuilder = this.productRepository + .createQueryBuilder('product') + .where('product.tenantId = :tenantId', { tenantId }) + .andWhere('product.deletedAt IS NULL'); + + // Apply search filter + if (search) { + queryBuilder.andWhere( + '(product.name ILIKE :search OR product.code ILIKE :search OR product.barcode ILIKE :search)', + { search: `%${search}%` } + ); + } + + // Filter by category + if (categoryId) { + queryBuilder.andWhere('product.categoryId = :categoryId', { categoryId }); + } + + // Filter by product type + if (productType) { + queryBuilder.andWhere('product.productType = :productType', { productType }); + } + + // Filter by can be sold + if (canBeSold !== undefined) { + queryBuilder.andWhere('product.canBeSold = :canBeSold', { canBeSold }); + } + + // Filter by can be purchased + if (canBePurchased !== undefined) { + queryBuilder.andWhere('product.canBePurchased = :canBePurchased', { canBePurchased }); + } + + // Filter by active status + if (active !== undefined) { + queryBuilder.andWhere('product.active = :active', { active }); + } + + // Get total count + const total = await queryBuilder.getCount(); + + // Get paginated results + const products = await queryBuilder + .orderBy('product.name', 'ASC') + .skip(skip) + .take(limit) + .getMany(); + + // Note: categoryName, uomName, purchaseUomName would need joins to core schema tables + // For now, we return the products as-is. If needed, these can be fetched with raw queries. + const data: ProductWithRelations[] = products; + + logger.debug('Products retrieved', { tenantId, count: data.length, total }); + + return { data, total }; + } catch (error) { + logger.error('Error retrieving products', { + error: (error as Error).message, + tenantId, + }); + throw error; + } + } + + /** + * Get product by ID + */ + async findById(id: string, tenantId: string): Promise { + try { + const product = await this.productRepository.findOne({ + where: { + id, + tenantId, + deletedAt: IsNull(), + }, + }); + + if (!product) { + throw new NotFoundError('Producto no encontrado'); + } + + return product; + } catch (error) { + logger.error('Error finding product', { + error: (error as Error).message, + id, + tenantId, + }); + throw error; + } + } + + /** + * Get product by code + */ + async findByCode(code: string, tenantId: string): Promise { + return this.productRepository.findOne({ + where: { + code, + tenantId, + deletedAt: IsNull(), + }, + }); + } + + /** + * Create a new product + */ + async create(dto: CreateProductDto, tenantId: string, userId: string): Promise { + try { + // Check unique code + if (dto.code) { + const existing = await this.findByCode(dto.code, tenantId); + if (existing) { + throw new ConflictError(`Ya existe un producto con código ${dto.code}`); + } + } + + // Check unique barcode + if (dto.barcode) { + const existingBarcode = await this.productRepository.findOne({ + where: { + barcode: dto.barcode, + deletedAt: IsNull(), + }, + }); + if (existingBarcode) { + throw new ConflictError(`Ya existe un producto con código de barras ${dto.barcode}`); + } + } + + // Create product + const product = this.productRepository.create({ + tenantId, + name: dto.name, + code: dto.code || null, + barcode: dto.barcode || null, + description: dto.description || null, + productType: dto.productType || ProductType.STORABLE, + tracking: dto.tracking || TrackingType.NONE, + categoryId: dto.categoryId || null, + uomId: dto.uomId, + purchaseUomId: dto.purchaseUomId || null, + costPrice: dto.costPrice || 0, + listPrice: dto.listPrice || 0, + valuationMethod: dto.valuationMethod || ValuationMethod.FIFO, + weight: dto.weight || null, + volume: dto.volume || null, + canBeSold: dto.canBeSold !== false, + canBePurchased: dto.canBePurchased !== false, + imageUrl: dto.imageUrl || null, + createdBy: userId, + }); + + await this.productRepository.save(product); + + logger.info('Product created', { + productId: product.id, + tenantId, + name: product.name, + createdBy: userId, + }); + + return product; + } catch (error) { + logger.error('Error creating product', { + error: (error as Error).message, + tenantId, + dto, + }); + throw error; + } + } + + /** + * Update a product + */ + async update(id: string, dto: UpdateProductDto, tenantId: string, userId: string): Promise { + try { + const existing = await this.findById(id, tenantId); + + // Check unique barcode if changing + if (dto.barcode !== undefined && dto.barcode !== existing.barcode) { + if (dto.barcode) { + const duplicate = await this.productRepository.findOne({ + where: { + barcode: dto.barcode, + deletedAt: IsNull(), + }, + }); + + if (duplicate && duplicate.id !== id) { + throw new ConflictError(`Ya existe un producto con código de barras ${dto.barcode}`); + } + } + } + + // Update allowed fields + if (dto.name !== undefined) existing.name = dto.name; + if (dto.barcode !== undefined) existing.barcode = dto.barcode; + if (dto.description !== undefined) existing.description = dto.description; + if (dto.tracking !== undefined) existing.tracking = dto.tracking; + if (dto.categoryId !== undefined) existing.categoryId = dto.categoryId; + if (dto.uomId !== undefined) existing.uomId = dto.uomId; + if (dto.purchaseUomId !== undefined) existing.purchaseUomId = dto.purchaseUomId; + if (dto.costPrice !== undefined) existing.costPrice = dto.costPrice; + if (dto.listPrice !== undefined) existing.listPrice = dto.listPrice; + if (dto.valuationMethod !== undefined) existing.valuationMethod = dto.valuationMethod; + if (dto.weight !== undefined) existing.weight = dto.weight; + if (dto.volume !== undefined) existing.volume = dto.volume; + if (dto.canBeSold !== undefined) existing.canBeSold = dto.canBeSold; + if (dto.canBePurchased !== undefined) existing.canBePurchased = dto.canBePurchased; + if (dto.imageUrl !== undefined) existing.imageUrl = dto.imageUrl; + if (dto.active !== undefined) existing.active = dto.active; + + existing.updatedBy = userId; + existing.updatedAt = new Date(); + + await this.productRepository.save(existing); + + logger.info('Product updated', { + productId: id, + tenantId, + updatedBy: userId, + }); + + return await this.findById(id, tenantId); + } catch (error) { + logger.error('Error updating product', { + error: (error as Error).message, + id, + tenantId, + }); + throw error; + } + } + + /** + * Soft delete a product + */ + async delete(id: string, tenantId: string, userId: string): Promise { + try { + await this.findById(id, tenantId); + + // Check if product has stock + const stockQuantCount = await this.stockQuantRepository + .createQueryBuilder('sq') + .where('sq.productId = :productId', { productId: id }) + .andWhere('sq.quantity > 0') + .getCount(); + + if (stockQuantCount > 0) { + throw new ConflictError('No se puede eliminar un producto que tiene stock'); + } + + // Soft delete + await this.productRepository.update( + { id, tenantId }, + { + deletedAt: new Date(), + deletedBy: userId, + active: false, + } + ); + + logger.info('Product deleted', { + productId: id, + tenantId, + deletedBy: userId, + }); + } catch (error) { + logger.error('Error deleting product', { + error: (error as Error).message, + id, + tenantId, + }); + throw error; + } + } + + /** + * Get stock for a product + */ + async getStock(productId: string, tenantId: string): Promise { + try { + await this.findById(productId, tenantId); + + const stock = await this.stockQuantRepository + .createQueryBuilder('sq') + .leftJoinAndSelect('sq.location', 'location') + .leftJoinAndSelect('location.warehouse', 'warehouse') + .where('sq.productId = :productId', { productId }) + .orderBy('warehouse.name', 'ASC') + .addOrderBy('location.name', 'ASC') + .getMany(); + + // Map to include relation names + return stock.map((sq) => ({ + id: sq.id, + productId: sq.productId, + locationId: sq.locationId, + locationName: sq.location?.name, + warehouseName: sq.location?.warehouse?.name, + lotId: sq.lotId, + quantity: sq.quantity, + reservedQuantity: sq.reservedQuantity, + createdAt: sq.createdAt, + updatedAt: sq.updatedAt, + })); + } catch (error) { + logger.error('Error getting product stock', { + error: (error as Error).message, + productId, + tenantId, + }); + throw error; + } + } +} + +// ===== Export Singleton Instance ===== + +export const productsService = new ProductsService(); diff --git a/src/modules/inventory/valuation.controller.ts b/src/modules/inventory/valuation.controller.ts new file mode 100644 index 0000000..01a9c7d --- /dev/null +++ b/src/modules/inventory/valuation.controller.ts @@ -0,0 +1,230 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { valuationService, CreateValuationLayerDto } from './valuation.service.js'; +import { AuthenticatedRequest, ValidationError, ApiResponse } from '../../shared/types/index.js'; + +// ============================================================================ +// VALIDATION SCHEMAS +// ============================================================================ + +const getProductCostSchema = z.object({ + product_id: z.string().uuid(), + company_id: z.string().uuid(), +}); + +const createLayerSchema = z.object({ + product_id: z.string().uuid(), + company_id: z.string().uuid(), + quantity: z.number().positive(), + unit_cost: z.number().nonnegative(), + stock_move_id: z.string().uuid().optional(), + description: z.string().max(255).optional(), +}); + +const consumeFifoSchema = z.object({ + product_id: z.string().uuid(), + company_id: z.string().uuid(), + quantity: z.number().positive(), +}); + +const productLayersSchema = z.object({ + company_id: z.string().uuid(), + include_empty: z.enum(['true', 'false']).optional(), +}); + +// ============================================================================ +// CONTROLLER +// ============================================================================ + +class ValuationController { + /** + * Get cost for a product based on its valuation method + * GET /api/inventory/valuation/cost + */ + async getProductCost(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = getProductCostSchema.safeParse(req.query); + if (!validation.success) { + throw new ValidationError('Parámetros inválidos', validation.error.errors); + } + + const { product_id, company_id } = validation.data; + const result = await valuationService.getProductCost( + product_id, + company_id, + req.user!.tenantId + ); + + const response: ApiResponse = { + success: true, + data: result, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Get valuation summary for a product + * GET /api/inventory/valuation/products/:productId/summary + */ + async getProductSummary(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { productId } = req.params; + const { company_id } = req.query; + + if (!company_id || typeof company_id !== 'string') { + throw new ValidationError('company_id es requerido'); + } + + const result = await valuationService.getProductValuationSummary( + productId, + company_id, + req.user!.tenantId + ); + + const response: ApiResponse = { + success: true, + data: result, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Get valuation layers for a product + * GET /api/inventory/valuation/products/:productId/layers + */ + async getProductLayers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { productId } = req.params; + const validation = productLayersSchema.safeParse(req.query); + + if (!validation.success) { + throw new ValidationError('Parámetros inválidos', validation.error.errors); + } + + const { company_id, include_empty } = validation.data; + const includeEmpty = include_empty === 'true'; + + const result = await valuationService.getProductLayers( + productId, + company_id, + req.user!.tenantId, + includeEmpty + ); + + const response: ApiResponse = { + success: true, + data: result, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Get company-wide valuation report + * GET /api/inventory/valuation/report + */ + async getCompanyReport(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { company_id } = req.query; + + if (!company_id || typeof company_id !== 'string') { + throw new ValidationError('company_id es requerido'); + } + + const result = await valuationService.getCompanyValuationReport( + company_id, + req.user!.tenantId + ); + + const response: ApiResponse = { + success: true, + data: result, + meta: { + total: result.length, + totalValue: result.reduce((sum, p) => sum + Number(p.total_value), 0), + }, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * Create a valuation layer manually (for adjustments) + * POST /api/inventory/valuation/layers + */ + async createLayer(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = createLayerSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const dto: CreateValuationLayerDto = validation.data; + + const result = await valuationService.createLayer( + dto, + req.user!.tenantId, + req.user!.userId + ); + + const response: ApiResponse = { + success: true, + data: result, + message: 'Capa de valoración creada', + }; + + res.status(201).json(response); + } catch (error) { + next(error); + } + } + + /** + * Consume stock using FIFO (for testing/manual adjustments) + * POST /api/inventory/valuation/consume + */ + async consumeFifo(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = consumeFifoSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const { product_id, company_id, quantity } = validation.data; + + const result = await valuationService.consumeFifo( + product_id, + company_id, + quantity, + req.user!.tenantId, + req.user!.userId + ); + + const response: ApiResponse = { + success: true, + data: result, + message: `Consumidas ${result.layers_consumed.length} capas FIFO`, + }; + + res.json(response); + } catch (error) { + next(error); + } + } +} + +export const valuationController = new ValuationController(); diff --git a/src/modules/inventory/valuation.service.ts b/src/modules/inventory/valuation.service.ts new file mode 100644 index 0000000..a4909a7 --- /dev/null +++ b/src/modules/inventory/valuation.service.ts @@ -0,0 +1,522 @@ +import { query, queryOne, getClient, PoolClient } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export type ValuationMethod = 'standard' | 'fifo' | 'average'; + +export interface StockValuationLayer { + id: string; + tenant_id: string; + product_id: string; + company_id: string; + quantity: number; + unit_cost: number; + value: number; + remaining_qty: number; + remaining_value: number; + stock_move_id?: string; + description?: string; + account_move_id?: string; + journal_entry_id?: string; + created_at: Date; +} + +export interface CreateValuationLayerDto { + product_id: string; + company_id: string; + quantity: number; + unit_cost: number; + stock_move_id?: string; + description?: string; +} + +export interface ValuationSummary { + product_id: string; + product_name: string; + product_code?: string; + total_quantity: number; + total_value: number; + average_cost: number; + valuation_method: ValuationMethod; + layer_count: number; +} + +export interface FifoConsumptionResult { + layers_consumed: { + layer_id: string; + quantity_consumed: number; + unit_cost: number; + value_consumed: number; + }[]; + total_cost: number; + weighted_average_cost: number; +} + +export interface ProductCostResult { + product_id: string; + valuation_method: ValuationMethod; + standard_cost: number; + fifo_cost?: number; + average_cost: number; + recommended_cost: number; +} + +// ============================================================================ +// SERVICE +// ============================================================================ + +class ValuationService { + /** + * Create a new valuation layer (for incoming stock) + * Used when receiving products via purchase orders or inventory adjustments + */ + async createLayer( + dto: CreateValuationLayerDto, + tenantId: string, + userId: string, + client?: PoolClient + ): Promise { + const executeQuery = client + ? (sql: string, params: any[]) => client.query(sql, params).then(r => r.rows[0]) + : queryOne; + + const value = dto.quantity * dto.unit_cost; + + const layer = await executeQuery( + `INSERT INTO inventory.stock_valuation_layers ( + tenant_id, product_id, company_id, quantity, unit_cost, value, + remaining_qty, remaining_value, stock_move_id, description, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $4, $6, $7, $8, $9) + RETURNING *`, + [ + tenantId, + dto.product_id, + dto.company_id, + dto.quantity, + dto.unit_cost, + value, + dto.stock_move_id, + dto.description, + userId, + ] + ); + + logger.info('Valuation layer created', { + layerId: layer?.id, + productId: dto.product_id, + quantity: dto.quantity, + unitCost: dto.unit_cost, + }); + + return layer as StockValuationLayer; + } + + /** + * Consume stock using FIFO method + * Returns the layers consumed and total cost + */ + async consumeFifo( + productId: string, + companyId: string, + quantity: number, + tenantId: string, + userId: string, + client?: PoolClient + ): Promise { + const dbClient = client || await getClient(); + const shouldReleaseClient = !client; + + try { + if (!client) { + await dbClient.query('BEGIN'); + } + + // Get available layers ordered by creation date (FIFO) + const layersResult = await dbClient.query( + `SELECT * FROM inventory.stock_valuation_layers + WHERE product_id = $1 AND company_id = $2 AND tenant_id = $3 + AND remaining_qty > 0 + ORDER BY created_at ASC + FOR UPDATE`, + [productId, companyId, tenantId] + ); + + const layers = layersResult.rows as StockValuationLayer[]; + let remainingToConsume = quantity; + const consumedLayers: FifoConsumptionResult['layers_consumed'] = []; + let totalCost = 0; + + for (const layer of layers) { + if (remainingToConsume <= 0) break; + + const consumeFromLayer = Math.min(remainingToConsume, Number(layer.remaining_qty)); + const valueConsumed = consumeFromLayer * Number(layer.unit_cost); + + // Update layer + await dbClient.query( + `UPDATE inventory.stock_valuation_layers + SET remaining_qty = remaining_qty - $1, + remaining_value = remaining_value - $2, + updated_at = NOW(), + updated_by = $3 + WHERE id = $4`, + [consumeFromLayer, valueConsumed, userId, layer.id] + ); + + consumedLayers.push({ + layer_id: layer.id, + quantity_consumed: consumeFromLayer, + unit_cost: Number(layer.unit_cost), + value_consumed: valueConsumed, + }); + + totalCost += valueConsumed; + remainingToConsume -= consumeFromLayer; + } + + if (remainingToConsume > 0) { + // Not enough stock in layers - this is a warning, not an error + // The stock might exist without valuation layers (e.g., initial data) + logger.warn('Insufficient valuation layers for FIFO consumption', { + productId, + requestedQty: quantity, + availableQty: quantity - remainingToConsume, + }); + } + + if (!client) { + await dbClient.query('COMMIT'); + } + + const weightedAvgCost = quantity > 0 ? totalCost / (quantity - remainingToConsume) : 0; + + return { + layers_consumed: consumedLayers, + total_cost: totalCost, + weighted_average_cost: weightedAvgCost, + }; + } catch (error) { + if (!client) { + await dbClient.query('ROLLBACK'); + } + throw error; + } finally { + if (shouldReleaseClient) { + dbClient.release(); + } + } + } + + /** + * Calculate the current cost of a product based on its valuation method + */ + async getProductCost( + productId: string, + companyId: string, + tenantId: string + ): Promise { + // Get product with its valuation method and standard cost + const product = await queryOne<{ + id: string; + valuation_method: ValuationMethod; + cost_price: number; + }>( + `SELECT id, valuation_method, cost_price + FROM inventory.products + WHERE id = $1 AND tenant_id = $2`, + [productId, tenantId] + ); + + if (!product) { + throw new NotFoundError('Producto no encontrado'); + } + + // Get FIFO cost (oldest layer's unit cost) + const oldestLayer = await queryOne<{ unit_cost: number }>( + `SELECT unit_cost FROM inventory.stock_valuation_layers + WHERE product_id = $1 AND company_id = $2 AND tenant_id = $3 + AND remaining_qty > 0 + ORDER BY created_at ASC + LIMIT 1`, + [productId, companyId, tenantId] + ); + + // Get average cost from all layers + const avgResult = await queryOne<{ avg_cost: number; total_qty: number }>( + `SELECT + CASE WHEN SUM(remaining_qty) > 0 + THEN SUM(remaining_value) / SUM(remaining_qty) + ELSE 0 + END as avg_cost, + SUM(remaining_qty) as total_qty + FROM inventory.stock_valuation_layers + WHERE product_id = $1 AND company_id = $2 AND tenant_id = $3 + AND remaining_qty > 0`, + [productId, companyId, tenantId] + ); + + const standardCost = Number(product.cost_price) || 0; + const fifoCost = oldestLayer ? Number(oldestLayer.unit_cost) : undefined; + const averageCost = Number(avgResult?.avg_cost) || 0; + + // Determine recommended cost based on valuation method + let recommendedCost: number; + switch (product.valuation_method) { + case 'fifo': + recommendedCost = fifoCost ?? standardCost; + break; + case 'average': + recommendedCost = averageCost > 0 ? averageCost : standardCost; + break; + case 'standard': + default: + recommendedCost = standardCost; + break; + } + + return { + product_id: productId, + valuation_method: product.valuation_method, + standard_cost: standardCost, + fifo_cost: fifoCost, + average_cost: averageCost, + recommended_cost: recommendedCost, + }; + } + + /** + * Get valuation summary for a product + */ + async getProductValuationSummary( + productId: string, + companyId: string, + tenantId: string + ): Promise { + const result = await queryOne( + `SELECT + p.id as product_id, + p.name as product_name, + p.code as product_code, + p.valuation_method, + COALESCE(SUM(svl.remaining_qty), 0) as total_quantity, + COALESCE(SUM(svl.remaining_value), 0) as total_value, + CASE WHEN COALESCE(SUM(svl.remaining_qty), 0) > 0 + THEN COALESCE(SUM(svl.remaining_value), 0) / SUM(svl.remaining_qty) + ELSE p.cost_price + END as average_cost, + COUNT(CASE WHEN svl.remaining_qty > 0 THEN 1 END) as layer_count + FROM inventory.products p + LEFT JOIN inventory.stock_valuation_layers svl + ON p.id = svl.product_id + AND svl.company_id = $2 + AND svl.tenant_id = $3 + WHERE p.id = $1 AND p.tenant_id = $3 + GROUP BY p.id, p.name, p.code, p.valuation_method, p.cost_price`, + [productId, companyId, tenantId] + ); + + return result; + } + + /** + * Get all valuation layers for a product + */ + async getProductLayers( + productId: string, + companyId: string, + tenantId: string, + includeEmpty: boolean = false + ): Promise { + const whereClause = includeEmpty + ? '' + : 'AND remaining_qty > 0'; + + return query( + `SELECT * FROM inventory.stock_valuation_layers + WHERE product_id = $1 AND company_id = $2 AND tenant_id = $3 + ${whereClause} + ORDER BY created_at ASC`, + [productId, companyId, tenantId] + ); + } + + /** + * Get inventory valuation report for a company + */ + async getCompanyValuationReport( + companyId: string, + tenantId: string + ): Promise { + return query( + `SELECT + p.id as product_id, + p.name as product_name, + p.code as product_code, + p.valuation_method, + COALESCE(SUM(svl.remaining_qty), 0) as total_quantity, + COALESCE(SUM(svl.remaining_value), 0) as total_value, + CASE WHEN COALESCE(SUM(svl.remaining_qty), 0) > 0 + THEN COALESCE(SUM(svl.remaining_value), 0) / SUM(svl.remaining_qty) + ELSE p.cost_price + END as average_cost, + COUNT(CASE WHEN svl.remaining_qty > 0 THEN 1 END) as layer_count + FROM inventory.products p + LEFT JOIN inventory.stock_valuation_layers svl + ON p.id = svl.product_id + AND svl.company_id = $1 + AND svl.tenant_id = $2 + WHERE p.tenant_id = $2 + AND p.product_type = 'storable' + AND p.active = true + GROUP BY p.id, p.name, p.code, p.valuation_method, p.cost_price + HAVING COALESCE(SUM(svl.remaining_qty), 0) > 0 + ORDER BY p.name`, + [companyId, tenantId] + ); + } + + /** + * Update average cost on product after valuation changes + * Call this after creating layers or consuming stock + */ + async updateProductAverageCost( + productId: string, + companyId: string, + tenantId: string, + client?: PoolClient + ): Promise { + const executeQuery = client + ? (sql: string, params: any[]) => client.query(sql, params) + : query; + + // Only update products using average cost method + await executeQuery( + `UPDATE inventory.products p + SET cost_price = ( + SELECT CASE WHEN SUM(svl.remaining_qty) > 0 + THEN SUM(svl.remaining_value) / SUM(svl.remaining_qty) + ELSE p.cost_price + END + FROM inventory.stock_valuation_layers svl + WHERE svl.product_id = p.id + AND svl.company_id = $2 + AND svl.tenant_id = $3 + AND svl.remaining_qty > 0 + ), + updated_at = NOW() + WHERE p.id = $1 + AND p.tenant_id = $3 + AND p.valuation_method = 'average'`, + [productId, companyId, tenantId] + ); + } + + /** + * Process stock move for valuation + * Creates or consumes valuation layers based on move direction + */ + async processStockMoveValuation( + moveId: string, + tenantId: string, + userId: string + ): Promise { + const move = await queryOne<{ + id: string; + product_id: string; + product_qty: number; + location_id: string; + location_dest_id: string; + company_id: string; + }>( + `SELECT sm.id, sm.product_id, sm.product_qty, + sm.location_id, sm.location_dest_id, + p.company_id + FROM inventory.stock_moves sm + JOIN inventory.pickings p ON sm.picking_id = p.id + WHERE sm.id = $1 AND sm.tenant_id = $2`, + [moveId, tenantId] + ); + + if (!move) { + throw new NotFoundError('Movimiento no encontrado'); + } + + // Get location types + const [srcLoc, destLoc] = await Promise.all([ + queryOne<{ location_type: string }>( + 'SELECT location_type FROM inventory.locations WHERE id = $1', + [move.location_id] + ), + queryOne<{ location_type: string }>( + 'SELECT location_type FROM inventory.locations WHERE id = $1', + [move.location_dest_id] + ), + ]); + + const srcIsInternal = srcLoc?.location_type === 'internal'; + const destIsInternal = destLoc?.location_type === 'internal'; + + // Get product cost for new layers + const product = await queryOne<{ cost_price: number; valuation_method: string }>( + 'SELECT cost_price, valuation_method FROM inventory.products WHERE id = $1', + [move.product_id] + ); + + if (!product) return; + + const client = await getClient(); + + try { + await client.query('BEGIN'); + + // Incoming to internal location (create layer) + if (!srcIsInternal && destIsInternal) { + await this.createLayer({ + product_id: move.product_id, + company_id: move.company_id, + quantity: Number(move.product_qty), + unit_cost: Number(product.cost_price), + stock_move_id: move.id, + description: `Recepción - Move ${move.id}`, + }, tenantId, userId, client); + } + + // Outgoing from internal location (consume layer with FIFO) + if (srcIsInternal && !destIsInternal) { + if (product.valuation_method === 'fifo' || product.valuation_method === 'average') { + await this.consumeFifo( + move.product_id, + move.company_id, + Number(move.product_qty), + tenantId, + userId, + client + ); + } + } + + // Update average cost if using that method + if (product.valuation_method === 'average') { + await this.updateProductAverageCost( + move.product_id, + move.company_id, + tenantId, + client + ); + } + + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } +} + +export const valuationService = new ValuationService(); diff --git a/src/modules/inventory/warehouses.service.ts b/src/modules/inventory/warehouses.service.ts new file mode 100644 index 0000000..f000c57 --- /dev/null +++ b/src/modules/inventory/warehouses.service.ts @@ -0,0 +1,283 @@ +import { Repository, IsNull } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { Warehouse } from './entities/warehouse.entity.js'; +import { Location } from './entities/location.entity.js'; +import { StockQuant } from './entities/stock-quant.entity.js'; +import { NotFoundError, ValidationError, ConflictError } from '../../shared/types/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ===== Interfaces ===== + +export interface CreateWarehouseDto { + companyId: string; + name: string; + code: string; + addressId?: string; + isDefault?: boolean; +} + +export interface UpdateWarehouseDto { + name?: string; + addressId?: string | null; + isDefault?: boolean; + active?: boolean; +} + +export interface WarehouseFilters { + companyId?: string; + active?: boolean; + page?: number; + limit?: number; +} + +export interface WarehouseWithRelations extends Warehouse { + companyName?: string; +} + +// ===== Service Class ===== + +class WarehousesService { + private warehouseRepository: Repository; + private locationRepository: Repository; + private stockQuantRepository: Repository; + + constructor() { + this.warehouseRepository = AppDataSource.getRepository(Warehouse); + this.locationRepository = AppDataSource.getRepository(Location); + this.stockQuantRepository = AppDataSource.getRepository(StockQuant); + } + + async findAll( + tenantId: string, + filters: WarehouseFilters = {} + ): Promise<{ data: WarehouseWithRelations[]; total: number }> { + try { + const { companyId, active, page = 1, limit = 50 } = filters; + const skip = (page - 1) * limit; + + const queryBuilder = this.warehouseRepository + .createQueryBuilder('warehouse') + .leftJoinAndSelect('warehouse.company', 'company') + .where('warehouse.tenantId = :tenantId', { tenantId }); + + if (companyId) { + queryBuilder.andWhere('warehouse.companyId = :companyId', { companyId }); + } + + if (active !== undefined) { + queryBuilder.andWhere('warehouse.active = :active', { active }); + } + + const total = await queryBuilder.getCount(); + + const warehouses = await queryBuilder + .orderBy('warehouse.name', 'ASC') + .skip(skip) + .take(limit) + .getMany(); + + const data: WarehouseWithRelations[] = warehouses.map(w => ({ + ...w, + companyName: w.company?.name, + })); + + logger.debug('Warehouses retrieved', { tenantId, count: data.length, total }); + + return { data, total }; + } catch (error) { + logger.error('Error retrieving warehouses', { + error: (error as Error).message, + tenantId, + }); + throw error; + } + } + + async findById(id: string, tenantId: string): Promise { + try { + const warehouse = await this.warehouseRepository + .createQueryBuilder('warehouse') + .leftJoinAndSelect('warehouse.company', 'company') + .where('warehouse.id = :id', { id }) + .andWhere('warehouse.tenantId = :tenantId', { tenantId }) + .getOne(); + + if (!warehouse) { + throw new NotFoundError('Almacén no encontrado'); + } + + return { + ...warehouse, + companyName: warehouse.company?.name, + }; + } catch (error) { + logger.error('Error finding warehouse', { + error: (error as Error).message, + id, + tenantId, + }); + throw error; + } + } + + async create(dto: CreateWarehouseDto, tenantId: string, userId: string): Promise { + try { + // Check unique code within company + const existing = await this.warehouseRepository.findOne({ + where: { + companyId: dto.companyId, + code: dto.code, + }, + }); + + if (existing) { + throw new ConflictError(`Ya existe un almacén con código ${dto.code} en esta empresa`); + } + + // If is_default, clear other defaults for company + if (dto.isDefault) { + await this.warehouseRepository.update( + { companyId: dto.companyId, tenantId }, + { isDefault: false } + ); + } + + const warehouse = this.warehouseRepository.create({ + tenantId, + companyId: dto.companyId, + name: dto.name, + code: dto.code, + addressId: dto.addressId || null, + isDefault: dto.isDefault || false, + createdBy: userId, + }); + + await this.warehouseRepository.save(warehouse); + + logger.info('Warehouse created', { + warehouseId: warehouse.id, + tenantId, + name: warehouse.name, + createdBy: userId, + }); + + return warehouse; + } catch (error) { + logger.error('Error creating warehouse', { + error: (error as Error).message, + tenantId, + dto, + }); + throw error; + } + } + + async update(id: string, dto: UpdateWarehouseDto, tenantId: string, userId: string): Promise { + try { + const existing = await this.findById(id, tenantId); + + // If setting as default, clear other defaults + if (dto.isDefault) { + await this.warehouseRepository + .createQueryBuilder() + .update(Warehouse) + .set({ isDefault: false }) + .where('companyId = :companyId', { companyId: existing.companyId }) + .andWhere('tenantId = :tenantId', { tenantId }) + .andWhere('id != :id', { id }) + .execute(); + } + + if (dto.name !== undefined) existing.name = dto.name; + if (dto.addressId !== undefined) existing.addressId = dto.addressId; + if (dto.isDefault !== undefined) existing.isDefault = dto.isDefault; + if (dto.active !== undefined) existing.active = dto.active; + + existing.updatedBy = userId; + existing.updatedAt = new Date(); + + await this.warehouseRepository.save(existing); + + logger.info('Warehouse updated', { + warehouseId: id, + tenantId, + updatedBy: userId, + }); + + return await this.findById(id, tenantId); + } catch (error) { + logger.error('Error updating warehouse', { + error: (error as Error).message, + id, + tenantId, + }); + throw error; + } + } + + async delete(id: string, tenantId: string): Promise { + try { + await this.findById(id, tenantId); + + // Check if warehouse has locations with stock + const hasStock = await this.stockQuantRepository + .createQueryBuilder('sq') + .innerJoin('sq.location', 'location') + .where('location.warehouseId = :warehouseId', { warehouseId: id }) + .andWhere('sq.quantity > 0') + .getCount(); + + if (hasStock > 0) { + throw new ConflictError('No se puede eliminar un almacén que tiene stock'); + } + + await this.warehouseRepository.delete({ id, tenantId }); + + logger.info('Warehouse deleted', { + warehouseId: id, + tenantId, + }); + } catch (error) { + logger.error('Error deleting warehouse', { + error: (error as Error).message, + id, + tenantId, + }); + throw error; + } + } + + async getLocations(warehouseId: string, tenantId: string): Promise { + await this.findById(warehouseId, tenantId); + + return this.locationRepository.find({ + where: { + warehouseId, + tenantId, + }, + order: { name: 'ASC' }, + }); + } + + async getStock(warehouseId: string, tenantId: string): Promise { + await this.findById(warehouseId, tenantId); + + const stock = await this.stockQuantRepository + .createQueryBuilder('sq') + .innerJoinAndSelect('sq.product', 'product') + .innerJoinAndSelect('sq.location', 'location') + .where('location.warehouseId = :warehouseId', { warehouseId }) + .orderBy('product.name', 'ASC') + .addOrderBy('location.name', 'ASC') + .getMany(); + + return stock.map(sq => ({ + ...sq, + productName: sq.product?.name, + productCode: sq.product?.code, + locationName: sq.location?.name, + })); + } +} + +export const warehousesService = new WarehousesService(); diff --git a/src/modules/partners/entities/index.ts b/src/modules/partners/entities/index.ts new file mode 100644 index 0000000..d64c144 --- /dev/null +++ b/src/modules/partners/entities/index.ts @@ -0,0 +1 @@ +export { Partner, PartnerType } from './partner.entity.js'; diff --git a/src/modules/partners/entities/partner.entity.ts b/src/modules/partners/entities/partner.entity.ts new file mode 100644 index 0000000..5f59f9d --- /dev/null +++ b/src/modules/partners/entities/partner.entity.ts @@ -0,0 +1,132 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Tenant } from '../../auth/entities/tenant.entity.js'; +import { Company } from '../../auth/entities/company.entity.js'; + +export type PartnerType = 'person' | 'company'; + +@Entity({ schema: 'core', name: 'partners' }) +@Index('idx_partners_tenant_id', ['tenantId']) +@Index('idx_partners_company_id', ['companyId']) +@Index('idx_partners_parent_id', ['parentId']) +@Index('idx_partners_active', ['tenantId', 'active'], { where: 'deleted_at IS NULL' }) +@Index('idx_partners_is_customer', ['tenantId', 'isCustomer'], { where: 'deleted_at IS NULL AND is_customer = true' }) +@Index('idx_partners_is_supplier', ['tenantId', 'isSupplier'], { where: 'deleted_at IS NULL AND is_supplier = true' }) +@Index('idx_partners_is_employee', ['tenantId', 'isEmployee'], { where: 'deleted_at IS NULL AND is_employee = true' }) +@Index('idx_partners_tax_id', ['taxId']) +@Index('idx_partners_email', ['email']) +export class Partner { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: false, name: 'tenant_id' }) + tenantId: string; + + @Column({ type: 'varchar', length: 255, nullable: false }) + name: string; + + @Column({ type: 'varchar', length: 255, nullable: true, name: 'legal_name' }) + legalName: string | null; + + @Column({ + type: 'varchar', + length: 20, + nullable: false, + default: 'person', + name: 'partner_type', + }) + partnerType: PartnerType; + + @Column({ type: 'boolean', default: false, name: 'is_customer' }) + isCustomer: boolean; + + @Column({ type: 'boolean', default: false, name: 'is_supplier' }) + isSupplier: boolean; + + @Column({ type: 'boolean', default: false, name: 'is_employee' }) + isEmployee: boolean; + + @Column({ type: 'boolean', default: false, name: 'is_company' }) + isCompany: boolean; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string | null; + + @Column({ type: 'varchar', length: 50, nullable: true }) + phone: string | null; + + @Column({ type: 'varchar', length: 50, nullable: true }) + mobile: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + website: string | null; + + @Column({ type: 'varchar', length: 50, nullable: true, name: 'tax_id' }) + taxId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'company_id' }) + companyId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'parent_id' }) + parentId: string | null; + + @Column({ type: 'uuid', nullable: true, name: 'currency_id' }) + currencyId: string | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @Column({ type: 'boolean', default: true }) + active: boolean; + + // Relaciones + @ManyToOne(() => Tenant, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'tenant_id' }) + tenant: Tenant; + + @ManyToOne(() => Company, { nullable: true }) + @JoinColumn({ name: 'company_id' }) + company: Company | null; + + @ManyToOne(() => Partner, (partner) => partner.children, { nullable: true }) + @JoinColumn({ name: 'parent_id' }) + parentPartner: Partner | null; + + children: Partner[]; + + // Auditoría + @CreateDateColumn({ name: 'created_at', type: 'timestamp' }) + createdAt: Date; + + @Column({ type: 'uuid', nullable: true, name: 'created_by' }) + createdBy: string | null; + + @UpdateDateColumn({ + name: 'updated_at', + type: 'timestamp', + nullable: true, + }) + updatedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'updated_by' }) + updatedBy: string | null; + + @Column({ type: 'timestamp', nullable: true, name: 'deleted_at' }) + deletedAt: Date | null; + + @Column({ type: 'uuid', nullable: true, name: 'deleted_by' }) + deletedBy: string | null; + + // Virtual fields for joined data + companyName?: string; + currencyCode?: string; + parentName?: string; +} diff --git a/src/modules/partners/index.ts b/src/modules/partners/index.ts new file mode 100644 index 0000000..4a0be6c --- /dev/null +++ b/src/modules/partners/index.ts @@ -0,0 +1,6 @@ +export * from './entities/index.js'; +export * from './partners.service.js'; +export * from './partners.controller.js'; +export * from './ranking.service.js'; +export * from './ranking.controller.js'; +export { default as partnersRoutes } from './partners.routes.js'; diff --git a/src/modules/partners/partners.controller.ts b/src/modules/partners/partners.controller.ts new file mode 100644 index 0000000..30825ac --- /dev/null +++ b/src/modules/partners/partners.controller.ts @@ -0,0 +1,333 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { partnersService, CreatePartnerDto, UpdatePartnerDto, PartnerFilters } from './partners.service.js'; +import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js'; + +// Validation schemas (accept both snake_case and camelCase from frontend) +const createPartnerSchema = z.object({ + name: z.string().min(1, 'El nombre es requerido').max(255), + legal_name: z.string().max(255).optional(), + legalName: z.string().max(255).optional(), + partner_type: z.enum(['person', 'company']).default('person'), + partnerType: z.enum(['person', 'company']).default('person'), + is_customer: z.boolean().default(false), + isCustomer: z.boolean().default(false), + is_supplier: z.boolean().default(false), + isSupplier: z.boolean().default(false), + is_employee: z.boolean().default(false), + isEmployee: z.boolean().default(false), + is_company: z.boolean().default(false), + isCompany: z.boolean().default(false), + email: z.string().email('Email inválido').max(255).optional(), + phone: z.string().max(50).optional(), + mobile: z.string().max(50).optional(), + website: z.string().url('URL inválida').max(255).optional(), + tax_id: z.string().max(50).optional(), + taxId: z.string().max(50).optional(), + company_id: z.string().uuid().optional(), + companyId: z.string().uuid().optional(), + parent_id: z.string().uuid().optional(), + parentId: z.string().uuid().optional(), + currency_id: z.string().uuid().optional(), + currencyId: z.string().uuid().optional(), + notes: z.string().optional(), +}); + +const updatePartnerSchema = z.object({ + name: z.string().min(1).max(255).optional(), + legal_name: z.string().max(255).optional().nullable(), + legalName: z.string().max(255).optional().nullable(), + is_customer: z.boolean().optional(), + isCustomer: z.boolean().optional(), + is_supplier: z.boolean().optional(), + isSupplier: z.boolean().optional(), + is_employee: z.boolean().optional(), + isEmployee: z.boolean().optional(), + email: z.string().email('Email inválido').max(255).optional().nullable(), + phone: z.string().max(50).optional().nullable(), + mobile: z.string().max(50).optional().nullable(), + website: z.string().url('URL inválida').max(255).optional().nullable(), + tax_id: z.string().max(50).optional().nullable(), + taxId: z.string().max(50).optional().nullable(), + company_id: z.string().uuid().optional().nullable(), + companyId: z.string().uuid().optional().nullable(), + parent_id: z.string().uuid().optional().nullable(), + parentId: z.string().uuid().optional().nullable(), + currency_id: z.string().uuid().optional().nullable(), + currencyId: z.string().uuid().optional().nullable(), + notes: z.string().optional().nullable(), + active: z.boolean().optional(), +}); + +const querySchema = z.object({ + search: z.string().optional(), + is_customer: z.coerce.boolean().optional(), + isCustomer: z.coerce.boolean().optional(), + is_supplier: z.coerce.boolean().optional(), + isSupplier: z.coerce.boolean().optional(), + is_employee: z.coerce.boolean().optional(), + isEmployee: z.coerce.boolean().optional(), + company_id: z.string().uuid().optional(), + companyId: z.string().uuid().optional(), + active: z.coerce.boolean().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +class PartnersController { + async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = querySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const data = queryResult.data; + const tenantId = req.user!.tenantId; + const filters: PartnerFilters = { + search: data.search, + isCustomer: data.isCustomer ?? data.is_customer, + isSupplier: data.isSupplier ?? data.is_supplier, + isEmployee: data.isEmployee ?? data.is_employee, + companyId: data.companyId || data.company_id, + active: data.active, + page: data.page, + limit: data.limit, + }; + + const result = await partnersService.findAll(tenantId, filters); + + const response: ApiResponse = { + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page || 1, + limit: filters.limit || 20, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async findCustomers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = querySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const data = queryResult.data; + const tenantId = req.user!.tenantId; + const filters = { + search: data.search, + isEmployee: data.isEmployee ?? data.is_employee, + companyId: data.companyId || data.company_id, + active: data.active, + page: data.page, + limit: data.limit, + }; + + const result = await partnersService.findCustomers(tenantId, filters); + + const response: ApiResponse = { + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page || 1, + limit: filters.limit || 20, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async findSuppliers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = querySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const data = queryResult.data; + const tenantId = req.user!.tenantId; + const filters = { + search: data.search, + isEmployee: data.isEmployee ?? data.is_employee, + companyId: data.companyId || data.company_id, + active: data.active, + page: data.page, + limit: data.limit, + }; + + const result = await partnersService.findSuppliers(tenantId, filters); + + const response: ApiResponse = { + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page || 1, + limit: filters.limit || 20, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async findById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const tenantId = req.user!.tenantId; + const partner = await partnersService.findById(id, tenantId); + + const response: ApiResponse = { + success: true, + data: partner, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createPartnerSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de contacto inválidos', parseResult.error.errors); + } + + const data = parseResult.data; + const tenantId = req.user!.tenantId; + const userId = req.user!.userId; + + // Transform to camelCase DTO + const dto: CreatePartnerDto = { + name: data.name, + legalName: data.legalName || data.legal_name, + partnerType: data.partnerType || data.partner_type, + isCustomer: data.isCustomer ?? data.is_customer, + isSupplier: data.isSupplier ?? data.is_supplier, + isEmployee: data.isEmployee ?? data.is_employee, + isCompany: data.isCompany ?? data.is_company, + email: data.email, + phone: data.phone, + mobile: data.mobile, + website: data.website, + taxId: data.taxId || data.tax_id, + companyId: data.companyId || data.company_id, + parentId: data.parentId || data.parent_id, + currencyId: data.currencyId || data.currency_id, + notes: data.notes, + }; + + const partner = await partnersService.create(dto, tenantId, userId); + + const response: ApiResponse = { + success: true, + data: partner, + message: 'Contacto creado exitosamente', + }; + + res.status(201).json(response); + } catch (error) { + next(error); + } + } + + async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const parseResult = updatePartnerSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de contacto inválidos', parseResult.error.errors); + } + + const data = parseResult.data; + const tenantId = req.user!.tenantId; + const userId = req.user!.userId; + + // Transform to camelCase DTO + const dto: UpdatePartnerDto = {}; + if (data.name !== undefined) dto.name = data.name; + if (data.legalName !== undefined || data.legal_name !== undefined) { + dto.legalName = data.legalName ?? data.legal_name; + } + if (data.isCustomer !== undefined || data.is_customer !== undefined) { + dto.isCustomer = data.isCustomer ?? data.is_customer; + } + if (data.isSupplier !== undefined || data.is_supplier !== undefined) { + dto.isSupplier = data.isSupplier ?? data.is_supplier; + } + if (data.isEmployee !== undefined || data.is_employee !== undefined) { + dto.isEmployee = data.isEmployee ?? data.is_employee; + } + if (data.email !== undefined) dto.email = data.email; + if (data.phone !== undefined) dto.phone = data.phone; + if (data.mobile !== undefined) dto.mobile = data.mobile; + if (data.website !== undefined) dto.website = data.website; + if (data.taxId !== undefined || data.tax_id !== undefined) { + dto.taxId = data.taxId ?? data.tax_id; + } + if (data.companyId !== undefined || data.company_id !== undefined) { + dto.companyId = data.companyId ?? data.company_id; + } + if (data.parentId !== undefined || data.parent_id !== undefined) { + dto.parentId = data.parentId ?? data.parent_id; + } + if (data.currencyId !== undefined || data.currency_id !== undefined) { + dto.currencyId = data.currencyId ?? data.currency_id; + } + if (data.notes !== undefined) dto.notes = data.notes; + if (data.active !== undefined) dto.active = data.active; + + const partner = await partnersService.update(id, dto, tenantId, userId); + + const response: ApiResponse = { + success: true, + data: partner, + message: 'Contacto actualizado exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const tenantId = req.user!.tenantId; + const userId = req.user!.userId; + + await partnersService.delete(id, tenantId, userId); + + const response: ApiResponse = { + success: true, + message: 'Contacto eliminado exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } +} + +export const partnersController = new PartnersController(); diff --git a/src/modules/partners/partners.routes.ts b/src/modules/partners/partners.routes.ts new file mode 100644 index 0000000..d4c65f7 --- /dev/null +++ b/src/modules/partners/partners.routes.ts @@ -0,0 +1,90 @@ +import { Router } from 'express'; +import { partnersController } from './partners.controller.js'; +import { rankingController } from './ranking.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// ============================================================================ +// RANKING ROUTES (must be before /:id routes to avoid conflicts) +// ============================================================================ + +// Calculate rankings (admin, manager) +router.post('/rankings/calculate', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + rankingController.calculateRankings(req, res, next) +); + +// Get all rankings +router.get('/rankings', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.findRankings(req, res, next) +); + +// Top partners +router.get('/rankings/top/customers', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.getTopCustomers(req, res, next) +); +router.get('/rankings/top/suppliers', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.getTopSuppliers(req, res, next) +); + +// ABC distribution +router.get('/rankings/abc/customers', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.getCustomerABCDistribution(req, res, next) +); +router.get('/rankings/abc/suppliers', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.getSupplierABCDistribution(req, res, next) +); + +// Partners by ABC +router.get('/rankings/abc/customers/:abc', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.getCustomersByABC(req, res, next) +); +router.get('/rankings/abc/suppliers/:abc', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.getSuppliersByABC(req, res, next) +); + +// Partner-specific ranking +router.get('/rankings/partner/:partnerId', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.findPartnerRanking(req, res, next) +); +router.get('/rankings/partner/:partnerId/history', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + rankingController.getPartnerHistory(req, res, next) +); + +// ============================================================================ +// PARTNER ROUTES +// ============================================================================ + +// Convenience endpoints for customers and suppliers +router.get('/customers', (req, res, next) => partnersController.findCustomers(req, res, next)); +router.get('/suppliers', (req, res, next) => partnersController.findSuppliers(req, res, next)); + +// List all partners (admin, manager, sales, accountant) +router.get('/', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + partnersController.findAll(req, res, next) +); + +// Get partner by ID +router.get('/:id', requireRoles('admin', 'manager', 'sales', 'accountant', 'super_admin'), (req, res, next) => + partnersController.findById(req, res, next) +); + +// Create partner (admin, manager, sales) +router.post('/', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + partnersController.create(req, res, next) +); + +// Update partner (admin, manager, sales) +router.put('/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + partnersController.update(req, res, next) +); + +// Delete partner (admin only) +router.delete('/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + partnersController.delete(req, res, next) +); + +export default router; diff --git a/src/modules/partners/partners.service.ts b/src/modules/partners/partners.service.ts new file mode 100644 index 0000000..6f6d552 --- /dev/null +++ b/src/modules/partners/partners.service.ts @@ -0,0 +1,395 @@ +import { Repository, IsNull } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { Partner, PartnerType } from './entities/index.js'; +import { NotFoundError, ValidationError, ForbiddenError } from '../../shared/types/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ===== Interfaces ===== + +export interface CreatePartnerDto { + name: string; + legalName?: string; + partnerType?: PartnerType; + isCustomer?: boolean; + isSupplier?: boolean; + isEmployee?: boolean; + isCompany?: boolean; + email?: string; + phone?: string; + mobile?: string; + website?: string; + taxId?: string; + companyId?: string; + parentId?: string; + currencyId?: string; + notes?: string; +} + +export interface UpdatePartnerDto { + name?: string; + legalName?: string | null; + isCustomer?: boolean; + isSupplier?: boolean; + isEmployee?: boolean; + email?: string | null; + phone?: string | null; + mobile?: string | null; + website?: string | null; + taxId?: string | null; + companyId?: string | null; + parentId?: string | null; + currencyId?: string | null; + notes?: string | null; + active?: boolean; +} + +export interface PartnerFilters { + search?: string; + isCustomer?: boolean; + isSupplier?: boolean; + isEmployee?: boolean; + companyId?: string; + active?: boolean; + page?: number; + limit?: number; +} + +export interface PartnerWithRelations extends Partner { + companyName?: string; + currencyCode?: string; + parentName?: string; +} + +// ===== PartnersService Class ===== + +class PartnersService { + private partnerRepository: Repository; + + constructor() { + this.partnerRepository = AppDataSource.getRepository(Partner); + } + + /** + * Get all partners for a tenant with filters and pagination + */ + async findAll( + tenantId: string, + filters: PartnerFilters = {} + ): Promise<{ data: PartnerWithRelations[]; total: number }> { + try { + const { search, isCustomer, isSupplier, isEmployee, companyId, active, page = 1, limit = 20 } = filters; + const skip = (page - 1) * limit; + + const queryBuilder = this.partnerRepository + .createQueryBuilder('partner') + .leftJoin('partner.company', 'company') + .addSelect(['company.name']) + .leftJoin('partner.parentPartner', 'parentPartner') + .addSelect(['parentPartner.name']) + .where('partner.tenantId = :tenantId', { tenantId }) + .andWhere('partner.deletedAt IS NULL'); + + // Apply search filter + if (search) { + queryBuilder.andWhere( + '(partner.name ILIKE :search OR partner.legalName ILIKE :search OR partner.email ILIKE :search OR partner.taxId ILIKE :search)', + { search: `%${search}%` } + ); + } + + // Filter by customer + if (isCustomer !== undefined) { + queryBuilder.andWhere('partner.isCustomer = :isCustomer', { isCustomer }); + } + + // Filter by supplier + if (isSupplier !== undefined) { + queryBuilder.andWhere('partner.isSupplier = :isSupplier', { isSupplier }); + } + + // Filter by employee + if (isEmployee !== undefined) { + queryBuilder.andWhere('partner.isEmployee = :isEmployee', { isEmployee }); + } + + // Filter by company + if (companyId) { + queryBuilder.andWhere('partner.companyId = :companyId', { companyId }); + } + + // Filter by active status + if (active !== undefined) { + queryBuilder.andWhere('partner.active = :active', { active }); + } + + // Get total count + const total = await queryBuilder.getCount(); + + // Get paginated results + const partners = await queryBuilder + .orderBy('partner.name', 'ASC') + .skip(skip) + .take(limit) + .getMany(); + + // Map to include relation names + const data: PartnerWithRelations[] = partners.map(partner => ({ + ...partner, + companyName: partner.company?.name, + parentName: partner.parentPartner?.name, + })); + + logger.debug('Partners retrieved', { tenantId, count: data.length, total }); + + return { data, total }; + } catch (error) { + logger.error('Error retrieving partners', { + error: (error as Error).message, + tenantId, + }); + throw error; + } + } + + /** + * Get partner by ID + */ + async findById(id: string, tenantId: string): Promise { + try { + const partner = await this.partnerRepository + .createQueryBuilder('partner') + .leftJoin('partner.company', 'company') + .addSelect(['company.name']) + .leftJoin('partner.parentPartner', 'parentPartner') + .addSelect(['parentPartner.name']) + .where('partner.id = :id', { id }) + .andWhere('partner.tenantId = :tenantId', { tenantId }) + .andWhere('partner.deletedAt IS NULL') + .getOne(); + + if (!partner) { + throw new NotFoundError('Contacto no encontrado'); + } + + return { + ...partner, + companyName: partner.company?.name, + parentName: partner.parentPartner?.name, + }; + } catch (error) { + logger.error('Error finding partner', { + error: (error as Error).message, + id, + tenantId, + }); + throw error; + } + } + + /** + * Create a new partner + */ + async create( + dto: CreatePartnerDto, + tenantId: string, + userId: string + ): Promise { + try { + // Validate parent partner exists + if (dto.parentId) { + const parent = await this.partnerRepository.findOne({ + where: { + id: dto.parentId, + tenantId, + deletedAt: IsNull(), + }, + }); + + if (!parent) { + throw new NotFoundError('Contacto padre no encontrado'); + } + } + + // Create partner + const partner = this.partnerRepository.create({ + tenantId, + name: dto.name, + legalName: dto.legalName || null, + partnerType: dto.partnerType || 'person', + isCustomer: dto.isCustomer || false, + isSupplier: dto.isSupplier || false, + isEmployee: dto.isEmployee || false, + isCompany: dto.isCompany || false, + email: dto.email?.toLowerCase() || null, + phone: dto.phone || null, + mobile: dto.mobile || null, + website: dto.website || null, + taxId: dto.taxId || null, + companyId: dto.companyId || null, + parentId: dto.parentId || null, + currencyId: dto.currencyId || null, + notes: dto.notes || null, + createdBy: userId, + }); + + await this.partnerRepository.save(partner); + + logger.info('Partner created', { + partnerId: partner.id, + tenantId, + name: partner.name, + createdBy: userId, + }); + + return partner; + } catch (error) { + logger.error('Error creating partner', { + error: (error as Error).message, + tenantId, + dto, + }); + throw error; + } + } + + /** + * Update a partner + */ + async update( + id: string, + dto: UpdatePartnerDto, + tenantId: string, + userId: string + ): Promise { + try { + const existing = await this.findById(id, tenantId); + + // Validate parent partner (prevent self-reference) + if (dto.parentId !== undefined && dto.parentId) { + if (dto.parentId === id) { + throw new ValidationError('Un contacto no puede ser su propio padre'); + } + + const parent = await this.partnerRepository.findOne({ + where: { + id: dto.parentId, + tenantId, + deletedAt: IsNull(), + }, + }); + + if (!parent) { + throw new NotFoundError('Contacto padre no encontrado'); + } + } + + // Update allowed fields + if (dto.name !== undefined) existing.name = dto.name; + if (dto.legalName !== undefined) existing.legalName = dto.legalName; + if (dto.isCustomer !== undefined) existing.isCustomer = dto.isCustomer; + if (dto.isSupplier !== undefined) existing.isSupplier = dto.isSupplier; + if (dto.isEmployee !== undefined) existing.isEmployee = dto.isEmployee; + if (dto.email !== undefined) existing.email = dto.email?.toLowerCase() || null; + if (dto.phone !== undefined) existing.phone = dto.phone; + if (dto.mobile !== undefined) existing.mobile = dto.mobile; + if (dto.website !== undefined) existing.website = dto.website; + if (dto.taxId !== undefined) existing.taxId = dto.taxId; + if (dto.companyId !== undefined) existing.companyId = dto.companyId; + if (dto.parentId !== undefined) existing.parentId = dto.parentId; + if (dto.currencyId !== undefined) existing.currencyId = dto.currencyId; + if (dto.notes !== undefined) existing.notes = dto.notes; + if (dto.active !== undefined) existing.active = dto.active; + + existing.updatedBy = userId; + existing.updatedAt = new Date(); + + await this.partnerRepository.save(existing); + + logger.info('Partner updated', { + partnerId: id, + tenantId, + updatedBy: userId, + }); + + return await this.findById(id, tenantId); + } catch (error) { + logger.error('Error updating partner', { + error: (error as Error).message, + id, + tenantId, + }); + throw error; + } + } + + /** + * Soft delete a partner + */ + async delete(id: string, tenantId: string, userId: string): Promise { + try { + await this.findById(id, tenantId); + + // Check if has child partners + const childrenCount = await this.partnerRepository.count({ + where: { + parentId: id, + tenantId, + deletedAt: IsNull(), + }, + }); + + if (childrenCount > 0) { + throw new ForbiddenError( + 'No se puede eliminar un contacto que tiene contactos relacionados' + ); + } + + // Soft delete + await this.partnerRepository.update( + { id, tenantId }, + { + deletedAt: new Date(), + deletedBy: userId, + active: false, + } + ); + + logger.info('Partner deleted', { + partnerId: id, + tenantId, + deletedBy: userId, + }); + } catch (error) { + logger.error('Error deleting partner', { + error: (error as Error).message, + id, + tenantId, + }); + throw error; + } + } + + /** + * Get customers only + */ + async findCustomers( + tenantId: string, + filters: Omit + ): Promise<{ data: PartnerWithRelations[]; total: number }> { + return this.findAll(tenantId, { ...filters, isCustomer: true }); + } + + /** + * Get suppliers only + */ + async findSuppliers( + tenantId: string, + filters: Omit + ): Promise<{ data: PartnerWithRelations[]; total: number }> { + return this.findAll(tenantId, { ...filters, isSupplier: true }); + } +} + +// ===== Export Singleton Instance ===== + +export const partnersService = new PartnersService(); diff --git a/src/modules/partners/ranking.controller.ts b/src/modules/partners/ranking.controller.ts new file mode 100644 index 0000000..95e15c1 --- /dev/null +++ b/src/modules/partners/ranking.controller.ts @@ -0,0 +1,368 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { AuthenticatedRequest } from '../../shared/types/index.js'; +import { rankingService, ABCClassification } from './ranking.service.js'; + +// ============================================================================ +// VALIDATION SCHEMAS +// ============================================================================ + +const calculateRankingsSchema = z.object({ + company_id: z.string().uuid().optional(), + period_start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + period_end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), +}); + +const rankingFiltersSchema = z.object({ + company_id: z.string().uuid().optional(), + period_start: z.string().optional(), + period_end: z.string().optional(), + customer_abc: z.enum(['A', 'B', 'C']).optional(), + supplier_abc: z.enum(['A', 'B', 'C']).optional(), + min_sales: z.coerce.number().min(0).optional(), + min_purchases: z.coerce.number().min(0).optional(), + page: z.coerce.number().min(1).default(1), + limit: z.coerce.number().min(1).max(100).default(20), +}); + +// ============================================================================ +// CONTROLLER +// ============================================================================ + +class RankingController { + /** + * POST /rankings/calculate + * Calculate partner rankings + */ + async calculateRankings( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { company_id, period_start, period_end } = calculateRankingsSchema.parse(req.body); + const tenantId = req.user!.tenantId; + + const result = await rankingService.calculateRankings( + tenantId, + company_id, + period_start, + period_end + ); + + res.json({ + success: true, + message: 'Rankings calculados exitosamente', + data: result, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings + * List all rankings with filters + */ + async findRankings( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const filters = rankingFiltersSchema.parse(req.query); + const tenantId = req.user!.tenantId; + + const { data, total } = await rankingService.findRankings(tenantId, filters); + + res.json({ + success: true, + data, + pagination: { + page: filters.page, + limit: filters.limit, + total, + totalPages: Math.ceil(total / filters.limit), + }, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings/partner/:partnerId + * Get ranking for a specific partner + */ + async findPartnerRanking( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { partnerId } = req.params; + const { period_start, period_end } = req.query as { + period_start?: string; + period_end?: string; + }; + const tenantId = req.user!.tenantId; + + const ranking = await rankingService.findPartnerRanking( + partnerId, + tenantId, + period_start, + period_end + ); + + if (!ranking) { + res.status(404).json({ + success: false, + error: 'No se encontró ranking para este contacto', + }); + return; + } + + res.json({ + success: true, + data: ranking, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings/partner/:partnerId/history + * Get ranking history for a partner + */ + async getPartnerHistory( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { partnerId } = req.params; + const limit = parseInt(req.query.limit as string) || 12; + const tenantId = req.user!.tenantId; + + const history = await rankingService.getPartnerRankingHistory( + partnerId, + tenantId, + Math.min(limit, 24) + ); + + res.json({ + success: true, + data: history, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings/top/customers + * Get top customers + */ + async getTopCustomers( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const limit = parseInt(req.query.limit as string) || 10; + const tenantId = req.user!.tenantId; + + const data = await rankingService.getTopPartners( + tenantId, + 'customers', + Math.min(limit, 50) + ); + + res.json({ + success: true, + data, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings/top/suppliers + * Get top suppliers + */ + async getTopSuppliers( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const limit = parseInt(req.query.limit as string) || 10; + const tenantId = req.user!.tenantId; + + const data = await rankingService.getTopPartners( + tenantId, + 'suppliers', + Math.min(limit, 50) + ); + + res.json({ + success: true, + data, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings/abc/customers + * Get ABC distribution for customers + */ + async getCustomerABCDistribution( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { company_id } = req.query as { company_id?: string }; + const tenantId = req.user!.tenantId; + + const distribution = await rankingService.getABCDistribution( + tenantId, + 'customers', + company_id + ); + + res.json({ + success: true, + data: distribution, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings/abc/suppliers + * Get ABC distribution for suppliers + */ + async getSupplierABCDistribution( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { company_id } = req.query as { company_id?: string }; + const tenantId = req.user!.tenantId; + + const distribution = await rankingService.getABCDistribution( + tenantId, + 'suppliers', + company_id + ); + + res.json({ + success: true, + data: distribution, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings/abc/customers/:abc + * Get customers by ABC classification + */ + async getCustomersByABC( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const abc = req.params.abc.toUpperCase() as ABCClassification; + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + const tenantId = req.user!.tenantId; + + if (!['A', 'B', 'C'].includes(abc || '')) { + res.status(400).json({ + success: false, + error: 'Clasificación ABC inválida. Use A, B o C.', + }); + return; + } + + const { data, total } = await rankingService.findPartnersByABC( + tenantId, + abc, + 'customers', + page, + Math.min(limit, 100) + ); + + res.json({ + success: true, + data, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /rankings/abc/suppliers/:abc + * Get suppliers by ABC classification + */ + async getSuppliersByABC( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const abc = req.params.abc.toUpperCase() as ABCClassification; + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 20; + const tenantId = req.user!.tenantId; + + if (!['A', 'B', 'C'].includes(abc || '')) { + res.status(400).json({ + success: false, + error: 'Clasificación ABC inválida. Use A, B o C.', + }); + return; + } + + const { data, total } = await rankingService.findPartnersByABC( + tenantId, + abc, + 'suppliers', + page, + Math.min(limit, 100) + ); + + res.json({ + success: true, + data, + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }); + } catch (error) { + next(error); + } + } +} + +export const rankingController = new RankingController(); diff --git a/src/modules/partners/ranking.service.ts b/src/modules/partners/ranking.service.ts new file mode 100644 index 0000000..2647315 --- /dev/null +++ b/src/modules/partners/ranking.service.ts @@ -0,0 +1,431 @@ +import { Repository, IsNull } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { Partner } from './entities/index.js'; +import { NotFoundError } from '../../shared/types/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export type ABCClassification = 'A' | 'B' | 'C' | null; + +export interface PartnerRanking { + id: string; + tenant_id: string; + partner_id: string; + partner_name?: string; + company_id: string | null; + period_start: Date; + period_end: Date; + total_sales: number; + sales_order_count: number; + avg_order_value: number; + total_purchases: number; + purchase_order_count: number; + avg_purchase_value: number; + avg_payment_days: number | null; + on_time_payment_rate: number | null; + sales_rank: number | null; + purchase_rank: number | null; + customer_abc: ABCClassification; + supplier_abc: ABCClassification; + customer_score: number | null; + supplier_score: number | null; + overall_score: number | null; + sales_trend: number | null; + purchase_trend: number | null; + calculated_at: Date; +} + +export interface RankingCalculationResult { + partners_processed: number; + customers_ranked: number; + suppliers_ranked: number; +} + +export interface RankingFilters { + company_id?: string; + period_start?: string; + period_end?: string; + customer_abc?: ABCClassification; + supplier_abc?: ABCClassification; + min_sales?: number; + min_purchases?: number; + page?: number; + limit?: number; +} + +export interface TopPartner { + id: string; + tenant_id: string; + name: string; + email: string | null; + is_customer: boolean; + is_supplier: boolean; + customer_rank: number | null; + supplier_rank: number | null; + customer_abc: ABCClassification; + supplier_abc: ABCClassification; + total_sales_ytd: number; + total_purchases_ytd: number; + last_ranking_date: Date | null; + customer_category: string | null; + supplier_category: string | null; +} + +// ============================================================================ +// SERVICE +// ============================================================================ + +class RankingService { + private partnerRepository: Repository; + + constructor() { + this.partnerRepository = AppDataSource.getRepository(Partner); + } + + /** + * Calculate rankings for all partners in a tenant + * Uses the database function for atomic calculation + */ + async calculateRankings( + tenantId: string, + companyId?: string, + periodStart?: string, + periodEnd?: string + ): Promise { + try { + const result = await this.partnerRepository.query( + `SELECT * FROM core.calculate_partner_rankings($1, $2, $3, $4)`, + [tenantId, companyId || null, periodStart || null, periodEnd || null] + ); + + const data = result[0]; + if (!data) { + throw new Error('Error calculando rankings'); + } + + logger.info('Partner rankings calculated', { + tenantId, + companyId, + periodStart, + periodEnd, + result: data, + }); + + return { + partners_processed: parseInt(data.partners_processed, 10), + customers_ranked: parseInt(data.customers_ranked, 10), + suppliers_ranked: parseInt(data.suppliers_ranked, 10), + }; + } catch (error) { + logger.error('Error calculating partner rankings', { + error: (error as Error).message, + tenantId, + companyId, + }); + throw error; + } + } + + /** + * Get rankings for a specific period + */ + async findRankings( + tenantId: string, + filters: RankingFilters = {} + ): Promise<{ data: PartnerRanking[]; total: number }> { + try { + const { + company_id, + period_start, + period_end, + customer_abc, + supplier_abc, + min_sales, + min_purchases, + page = 1, + limit = 20, + } = filters; + + const conditions: string[] = ['pr.tenant_id = $1']; + const params: any[] = [tenantId]; + let idx = 2; + + if (company_id) { + conditions.push(`pr.company_id = $${idx++}`); + params.push(company_id); + } + + if (period_start) { + conditions.push(`pr.period_start >= $${idx++}`); + params.push(period_start); + } + + if (period_end) { + conditions.push(`pr.period_end <= $${idx++}`); + params.push(period_end); + } + + if (customer_abc) { + conditions.push(`pr.customer_abc = $${idx++}`); + params.push(customer_abc); + } + + if (supplier_abc) { + conditions.push(`pr.supplier_abc = $${idx++}`); + params.push(supplier_abc); + } + + if (min_sales !== undefined) { + conditions.push(`pr.total_sales >= $${idx++}`); + params.push(min_sales); + } + + if (min_purchases !== undefined) { + conditions.push(`pr.total_purchases >= $${idx++}`); + params.push(min_purchases); + } + + const whereClause = conditions.join(' AND '); + + // Count total + const countResult = await this.partnerRepository.query( + `SELECT COUNT(*) as count FROM core.partner_rankings pr WHERE ${whereClause}`, + params + ); + + // Get data with pagination + const offset = (page - 1) * limit; + params.push(limit, offset); + + const data = await this.partnerRepository.query( + `SELECT pr.*, + p.name as partner_name + FROM core.partner_rankings pr + JOIN core.partners p ON pr.partner_id = p.id + WHERE ${whereClause} + ORDER BY pr.overall_score DESC NULLS LAST, pr.total_sales DESC + LIMIT $${idx} OFFSET $${idx + 1}`, + params + ); + + return { + data, + total: parseInt(countResult[0]?.count || '0', 10), + }; + } catch (error) { + logger.error('Error retrieving partner rankings', { + error: (error as Error).message, + tenantId, + }); + throw error; + } + } + + /** + * Get ranking for a specific partner + */ + async findPartnerRanking( + partnerId: string, + tenantId: string, + periodStart?: string, + periodEnd?: string + ): Promise { + try { + let sql = ` + SELECT pr.*, p.name as partner_name + FROM core.partner_rankings pr + JOIN core.partners p ON pr.partner_id = p.id + WHERE pr.partner_id = $1 AND pr.tenant_id = $2 + `; + const params: any[] = [partnerId, tenantId]; + + if (periodStart && periodEnd) { + sql += ` AND pr.period_start = $3 AND pr.period_end = $4`; + params.push(periodStart, periodEnd); + } else { + // Get most recent ranking + sql += ` ORDER BY pr.calculated_at DESC LIMIT 1`; + } + + const result = await this.partnerRepository.query(sql, params); + return result[0] || null; + } catch (error) { + logger.error('Error finding partner ranking', { + error: (error as Error).message, + partnerId, + tenantId, + }); + throw error; + } + } + + /** + * Get top partners (customers or suppliers) + */ + async getTopPartners( + tenantId: string, + type: 'customers' | 'suppliers', + limit: number = 10 + ): Promise { + try { + const orderColumn = type === 'customers' ? 'customer_rank' : 'supplier_rank'; + + const result = await this.partnerRepository.query( + `SELECT * FROM core.top_partners_view + WHERE tenant_id = $1 AND ${orderColumn} IS NOT NULL + ORDER BY ${orderColumn} ASC + LIMIT $2`, + [tenantId, limit] + ); + + return result; + } catch (error) { + logger.error('Error getting top partners', { + error: (error as Error).message, + tenantId, + type, + }); + throw error; + } + } + + /** + * Get ABC distribution summary + */ + async getABCDistribution( + tenantId: string, + type: 'customers' | 'suppliers', + companyId?: string + ): Promise<{ + A: { count: number; total_value: number; percentage: number }; + B: { count: number; total_value: number; percentage: number }; + C: { count: number; total_value: number; percentage: number }; + }> { + try { + const abcColumn = type === 'customers' ? 'customer_abc' : 'supplier_abc'; + const valueColumn = type === 'customers' ? 'total_sales_ytd' : 'total_purchases_ytd'; + + const whereClause = `tenant_id = $1 AND ${abcColumn} IS NOT NULL`; + const params: any[] = [tenantId]; + + const result = await this.partnerRepository.query( + `SELECT + ${abcColumn} as abc, + COUNT(*) as count, + COALESCE(SUM(${valueColumn}), 0) as total_value + FROM core.partners + WHERE ${whereClause} AND deleted_at IS NULL + GROUP BY ${abcColumn} + ORDER BY ${abcColumn}`, + params + ); + + // Calculate totals + const grandTotal = result.reduce((sum: number, r: any) => sum + parseFloat(r.total_value), 0); + + const distribution = { + A: { count: 0, total_value: 0, percentage: 0 }, + B: { count: 0, total_value: 0, percentage: 0 }, + C: { count: 0, total_value: 0, percentage: 0 }, + }; + + for (const row of result) { + const abc = row.abc as 'A' | 'B' | 'C'; + if (abc in distribution) { + distribution[abc] = { + count: parseInt(row.count, 10), + total_value: parseFloat(row.total_value), + percentage: grandTotal > 0 ? (parseFloat(row.total_value) / grandTotal) * 100 : 0, + }; + } + } + + return distribution; + } catch (error) { + logger.error('Error getting ABC distribution', { + error: (error as Error).message, + tenantId, + type, + }); + throw error; + } + } + + /** + * Get ranking history for a partner + */ + async getPartnerRankingHistory( + partnerId: string, + tenantId: string, + limit: number = 12 + ): Promise { + try { + const result = await this.partnerRepository.query( + `SELECT pr.*, p.name as partner_name + FROM core.partner_rankings pr + JOIN core.partners p ON pr.partner_id = p.id + WHERE pr.partner_id = $1 AND pr.tenant_id = $2 + ORDER BY pr.period_end DESC + LIMIT $3`, + [partnerId, tenantId, limit] + ); + + return result; + } catch (error) { + logger.error('Error getting partner ranking history', { + error: (error as Error).message, + partnerId, + tenantId, + }); + throw error; + } + } + + /** + * Get partners by ABC classification + */ + async findPartnersByABC( + tenantId: string, + abc: ABCClassification, + type: 'customers' | 'suppliers', + page: number = 1, + limit: number = 20 + ): Promise<{ data: TopPartner[]; total: number }> { + try { + const abcColumn = type === 'customers' ? 'customer_abc' : 'supplier_abc'; + const offset = (page - 1) * limit; + + const countResult = await this.partnerRepository.query( + `SELECT COUNT(*) as count FROM core.partners + WHERE tenant_id = $1 AND ${abcColumn} = $2 AND deleted_at IS NULL`, + [tenantId, abc] + ); + + const data = await this.partnerRepository.query( + `SELECT * FROM core.top_partners_view + WHERE tenant_id = $1 AND ${abcColumn} = $2 + ORDER BY ${type === 'customers' ? 'total_sales_ytd' : 'total_purchases_ytd'} DESC + LIMIT $3 OFFSET $4`, + [tenantId, abc, limit, offset] + ); + + return { + data, + total: parseInt(countResult[0]?.count || '0', 10), + }; + } catch (error) { + logger.error('Error finding partners by ABC', { + error: (error as Error).message, + tenantId, + abc, + type, + }); + throw error; + } + } +} + +export const rankingService = new RankingService(); diff --git a/src/modules/projects/index.ts b/src/modules/projects/index.ts new file mode 100644 index 0000000..8b83332 --- /dev/null +++ b/src/modules/projects/index.ts @@ -0,0 +1,5 @@ +export * from './projects.service.js'; +export * from './tasks.service.js'; +export * from './timesheets.service.js'; +export * from './projects.controller.js'; +export { default as projectsRoutes } from './projects.routes.js'; diff --git a/src/modules/projects/projects.controller.ts b/src/modules/projects/projects.controller.ts new file mode 100644 index 0000000..403ee8d --- /dev/null +++ b/src/modules/projects/projects.controller.ts @@ -0,0 +1,569 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { projectsService, CreateProjectDto, UpdateProjectDto, ProjectFilters } from './projects.service.js'; +import { tasksService, CreateTaskDto, UpdateTaskDto, TaskFilters } from './tasks.service.js'; +import { timesheetsService, CreateTimesheetDto, UpdateTimesheetDto, TimesheetFilters } from './timesheets.service.js'; +import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js'; +import { ValidationError } from '../../shared/errors/index.js'; + +// Project schemas +const createProjectSchema = z.object({ + company_id: z.string().uuid({ message: 'La empresa es requerida' }), + name: z.string().min(1, 'El nombre es requerido').max(255), + code: z.string().max(50).optional(), + description: z.string().optional(), + manager_id: z.string().uuid().optional(), + partner_id: z.string().uuid().optional(), + date_start: z.string().optional(), + date_end: z.string().optional(), + privacy: z.enum(['public', 'private', 'followers']).default('public'), + allow_timesheets: z.boolean().default(true), + color: z.string().max(20).optional(), +}); + +const updateProjectSchema = z.object({ + name: z.string().min(1).max(255).optional(), + code: z.string().max(50).optional().nullable(), + description: z.string().optional().nullable(), + manager_id: z.string().uuid().optional().nullable(), + partner_id: z.string().uuid().optional().nullable(), + date_start: z.string().optional().nullable(), + date_end: z.string().optional().nullable(), + status: z.enum(['draft', 'active', 'completed', 'cancelled', 'on_hold']).optional(), + privacy: z.enum(['public', 'private', 'followers']).optional(), + allow_timesheets: z.boolean().optional(), + color: z.string().max(20).optional().nullable(), +}); + +const projectQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + manager_id: z.string().uuid().optional(), + partner_id: z.string().uuid().optional(), + status: z.enum(['draft', 'active', 'completed', 'cancelled', 'on_hold']).optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +// Task schemas +const createTaskSchema = z.object({ + project_id: z.string().uuid({ message: 'El proyecto es requerido' }), + stage_id: z.string().uuid().optional(), + name: z.string().min(1, 'El nombre es requerido').max(255), + description: z.string().optional(), + assigned_to: z.string().uuid().optional(), + parent_id: z.string().uuid().optional(), + date_deadline: z.string().optional(), + estimated_hours: z.number().positive().optional(), + priority: z.enum(['low', 'normal', 'high', 'urgent']).default('normal'), + color: z.string().max(20).optional(), +}); + +const updateTaskSchema = z.object({ + stage_id: z.string().uuid().optional().nullable(), + name: z.string().min(1).max(255).optional(), + description: z.string().optional().nullable(), + assigned_to: z.string().uuid().optional().nullable(), + parent_id: z.string().uuid().optional().nullable(), + date_deadline: z.string().optional().nullable(), + estimated_hours: z.number().positive().optional().nullable(), + priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(), + status: z.enum(['todo', 'in_progress', 'review', 'done', 'cancelled']).optional(), + sequence: z.number().int().positive().optional(), + color: z.string().max(20).optional().nullable(), +}); + +const taskQuerySchema = z.object({ + project_id: z.string().uuid().optional(), + stage_id: z.string().uuid().optional(), + assigned_to: z.string().uuid().optional(), + status: z.enum(['todo', 'in_progress', 'review', 'done', 'cancelled']).optional(), + priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +const moveTaskSchema = z.object({ + stage_id: z.string().uuid().nullable(), + sequence: z.number().int().positive(), +}); + +const assignTaskSchema = z.object({ + user_id: z.string().uuid({ message: 'El usuario es requerido' }), +}); + +// Timesheet schemas +const createTimesheetSchema = z.object({ + company_id: z.string().uuid({ message: 'La empresa es requerida' }), + project_id: z.string().uuid({ message: 'El proyecto es requerido' }), + task_id: z.string().uuid().optional(), + date: z.string({ message: 'La fecha es requerida' }), + hours: z.number().positive('Las horas deben ser positivas').max(24), + description: z.string().optional(), + billable: z.boolean().default(true), +}); + +const updateTimesheetSchema = z.object({ + task_id: z.string().uuid().optional().nullable(), + date: z.string().optional(), + hours: z.number().positive().max(24).optional(), + description: z.string().optional().nullable(), + billable: z.boolean().optional(), +}); + +const timesheetQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + project_id: z.string().uuid().optional(), + task_id: z.string().uuid().optional(), + user_id: z.string().uuid().optional(), + status: z.enum(['draft', 'submitted', 'approved', 'rejected']).optional(), + date_from: z.string().optional(), + date_to: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +class ProjectsController { + // ========== PROJECTS ========== + async getProjects(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = projectQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: ProjectFilters = queryResult.data; + const result = await projectsService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + async getProject(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const project = await projectsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: project }); + } catch (error) { + next(error); + } + } + + async createProject(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createProjectSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de proyecto inválidos', parseResult.error.errors); + } + + const dto: CreateProjectDto = parseResult.data; + const project = await projectsService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: project, + message: 'Proyecto creado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateProject(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateProjectSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de proyecto inválidos', parseResult.error.errors); + } + + const dto: UpdateProjectDto = parseResult.data; + const project = await projectsService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: project, + message: 'Proyecto actualizado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async deleteProject(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await projectsService.delete(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, message: 'Proyecto eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + async getProjectStats(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const stats = await projectsService.getStats(req.params.id, req.tenantId!); + res.json({ success: true, data: stats }); + } catch (error) { + next(error); + } + } + + async getProjectTasks(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = taskQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: TaskFilters = { ...queryResult.data, project_id: req.params.id }; + const result = await tasksService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + async getProjectTimesheets(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = timesheetQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: TimesheetFilters = { ...queryResult.data, project_id: req.params.id }; + const result = await timesheetsService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + // ========== TASKS ========== + async getTasks(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = taskQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: TaskFilters = queryResult.data; + const result = await tasksService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + async getTask(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const task = await tasksService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: task }); + } catch (error) { + next(error); + } + } + + async createTask(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createTaskSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de tarea inválidos', parseResult.error.errors); + } + + const dto: CreateTaskDto = parseResult.data; + const task = await tasksService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: task, + message: 'Tarea creada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateTask(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateTaskSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de tarea inválidos', parseResult.error.errors); + } + + const dto: UpdateTaskDto = parseResult.data; + const task = await tasksService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: task, + message: 'Tarea actualizada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async deleteTask(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await tasksService.delete(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, message: 'Tarea eliminada exitosamente' }); + } catch (error) { + next(error); + } + } + + async moveTask(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = moveTaskSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de movimiento inválidos', parseResult.error.errors); + } + + const { stage_id, sequence } = parseResult.data; + const task = await tasksService.move(req.params.id, stage_id, sequence, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: task, + message: 'Tarea movida exitosamente', + }); + } catch (error) { + next(error); + } + } + + async assignTask(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = assignTaskSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de asignación inválidos', parseResult.error.errors); + } + + const { user_id } = parseResult.data; + const task = await tasksService.assign(req.params.id, user_id, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: task, + message: 'Tarea asignada exitosamente', + }); + } catch (error) { + next(error); + } + } + + // ========== TIMESHEETS ========== + async getTimesheets(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = timesheetQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: TimesheetFilters = queryResult.data; + const result = await timesheetsService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + async getTimesheet(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const timesheet = await timesheetsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: timesheet }); + } catch (error) { + next(error); + } + } + + async createTimesheet(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createTimesheetSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de timesheet inválidos', parseResult.error.errors); + } + + const dto: CreateTimesheetDto = parseResult.data; + const timesheet = await timesheetsService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: timesheet, + message: 'Tiempo registrado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateTimesheet(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateTimesheetSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de timesheet inválidos', parseResult.error.errors); + } + + const dto: UpdateTimesheetDto = parseResult.data; + const timesheet = await timesheetsService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: timesheet, + message: 'Timesheet actualizado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async deleteTimesheet(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await timesheetsService.delete(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, message: 'Timesheet eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + async submitTimesheet(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const timesheet = await timesheetsService.submit(req.params.id, req.tenantId!, req.user!.userId); + res.json({ + success: true, + data: timesheet, + message: 'Timesheet enviado para aprobación', + }); + } catch (error) { + next(error); + } + } + + async approveTimesheet(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const timesheet = await timesheetsService.approve(req.params.id, req.tenantId!, req.user!.userId); + res.json({ + success: true, + data: timesheet, + message: 'Timesheet aprobado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async rejectTimesheet(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const timesheet = await timesheetsService.reject(req.params.id, req.tenantId!, req.user!.userId); + res.json({ + success: true, + data: timesheet, + message: 'Timesheet rechazado', + }); + } catch (error) { + next(error); + } + } + + async getMyTimesheets(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = timesheetQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: TimesheetFilters = queryResult.data; + const result = await timesheetsService.getMyTimesheets(req.tenantId!, req.user!.userId, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + async getPendingApprovals(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = timesheetQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: TimesheetFilters = queryResult.data; + const result = await timesheetsService.getPendingApprovals(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } +} + +export const projectsController = new ProjectsController(); diff --git a/src/modules/projects/projects.routes.ts b/src/modules/projects/projects.routes.ts new file mode 100644 index 0000000..e5e9f2a --- /dev/null +++ b/src/modules/projects/projects.routes.ts @@ -0,0 +1,75 @@ +import { Router } from 'express'; +import { projectsController } from './projects.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// ========== PROJECTS ========== +router.get('/', (req, res, next) => projectsController.getProjects(req, res, next)); + +router.get('/:id', (req, res, next) => projectsController.getProject(req, res, next)); + +router.post('/', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + projectsController.createProject(req, res, next) +); + +router.put('/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + projectsController.updateProject(req, res, next) +); + +router.delete('/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + projectsController.deleteProject(req, res, next) +); + +router.get('/:id/stats', (req, res, next) => projectsController.getProjectStats(req, res, next)); + +router.get('/:id/tasks', (req, res, next) => projectsController.getProjectTasks(req, res, next)); + +router.get('/:id/timesheets', (req, res, next) => projectsController.getProjectTimesheets(req, res, next)); + +// ========== TASKS ========== +router.get('/tasks/all', (req, res, next) => projectsController.getTasks(req, res, next)); + +router.get('/tasks/:id', (req, res, next) => projectsController.getTask(req, res, next)); + +router.post('/tasks', (req, res, next) => projectsController.createTask(req, res, next)); + +router.put('/tasks/:id', (req, res, next) => projectsController.updateTask(req, res, next)); + +router.delete('/tasks/:id', (req, res, next) => projectsController.deleteTask(req, res, next)); + +router.post('/tasks/:id/move', (req, res, next) => projectsController.moveTask(req, res, next)); + +router.post('/tasks/:id/assign', (req, res, next) => projectsController.assignTask(req, res, next)); + +// ========== TIMESHEETS ========== +router.get('/timesheets/all', (req, res, next) => projectsController.getTimesheets(req, res, next)); + +router.get('/timesheets/me', (req, res, next) => projectsController.getMyTimesheets(req, res, next)); + +router.get('/timesheets/pending', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + projectsController.getPendingApprovals(req, res, next) +); + +router.get('/timesheets/:id', (req, res, next) => projectsController.getTimesheet(req, res, next)); + +router.post('/timesheets', (req, res, next) => projectsController.createTimesheet(req, res, next)); + +router.put('/timesheets/:id', (req, res, next) => projectsController.updateTimesheet(req, res, next)); + +router.delete('/timesheets/:id', (req, res, next) => projectsController.deleteTimesheet(req, res, next)); + +router.post('/timesheets/:id/submit', (req, res, next) => projectsController.submitTimesheet(req, res, next)); + +router.post('/timesheets/:id/approve', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + projectsController.approveTimesheet(req, res, next) +); + +router.post('/timesheets/:id/reject', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + projectsController.rejectTimesheet(req, res, next) +); + +export default router; diff --git a/src/modules/projects/projects.service.ts b/src/modules/projects/projects.service.ts new file mode 100644 index 0000000..136c8c0 --- /dev/null +++ b/src/modules/projects/projects.service.ts @@ -0,0 +1,309 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js'; + +export interface Project { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + name: string; + code?: string; + description?: string; + manager_id?: string; + manager_name?: string; + partner_id?: string; + partner_name?: string; + analytic_account_id?: string; + date_start?: Date; + date_end?: Date; + status: 'draft' | 'active' | 'completed' | 'cancelled' | 'on_hold'; + privacy: 'public' | 'private' | 'followers'; + allow_timesheets: boolean; + color?: string; + task_count?: number; + completed_task_count?: number; + created_at: Date; +} + +export interface CreateProjectDto { + company_id: string; + name: string; + code?: string; + description?: string; + manager_id?: string; + partner_id?: string; + date_start?: string; + date_end?: string; + privacy?: 'public' | 'private' | 'followers'; + allow_timesheets?: boolean; + color?: string; +} + +export interface UpdateProjectDto { + name?: string; + code?: string | null; + description?: string | null; + manager_id?: string | null; + partner_id?: string | null; + date_start?: string | null; + date_end?: string | null; + status?: 'draft' | 'active' | 'completed' | 'cancelled' | 'on_hold'; + privacy?: 'public' | 'private' | 'followers'; + allow_timesheets?: boolean; + color?: string | null; +} + +export interface ProjectFilters { + company_id?: string; + manager_id?: string; + partner_id?: string; + status?: string; + search?: string; + page?: number; + limit?: number; +} + +class ProjectsService { + async findAll(tenantId: string, filters: ProjectFilters = {}): Promise<{ data: Project[]; total: number }> { + const { company_id, manager_id, partner_id, status, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE p.tenant_id = $1 AND p.deleted_at IS NULL'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND p.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (manager_id) { + whereClause += ` AND p.manager_id = $${paramIndex++}`; + params.push(manager_id); + } + + if (partner_id) { + whereClause += ` AND p.partner_id = $${paramIndex++}`; + params.push(partner_id); + } + + if (status) { + whereClause += ` AND p.status = $${paramIndex++}`; + params.push(status); + } + + if (search) { + whereClause += ` AND (p.name ILIKE $${paramIndex} OR p.code ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM projects.projects p ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT p.*, + c.name as company_name, + u.name as manager_name, + pr.name as partner_name, + (SELECT COUNT(*) FROM projects.tasks t WHERE t.project_id = p.id AND t.deleted_at IS NULL) as task_count, + (SELECT COUNT(*) FROM projects.tasks t WHERE t.project_id = p.id AND t.status = 'done' AND t.deleted_at IS NULL) as completed_task_count + FROM projects.projects p + LEFT JOIN auth.companies c ON p.company_id = c.id + LEFT JOIN auth.users u ON p.manager_id = u.id + LEFT JOIN core.partners pr ON p.partner_id = pr.id + ${whereClause} + ORDER BY p.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const project = await queryOne( + `SELECT p.*, + c.name as company_name, + u.name as manager_name, + pr.name as partner_name, + (SELECT COUNT(*) FROM projects.tasks t WHERE t.project_id = p.id AND t.deleted_at IS NULL) as task_count, + (SELECT COUNT(*) FROM projects.tasks t WHERE t.project_id = p.id AND t.status = 'done' AND t.deleted_at IS NULL) as completed_task_count + FROM projects.projects p + LEFT JOIN auth.companies c ON p.company_id = c.id + LEFT JOIN auth.users u ON p.manager_id = u.id + LEFT JOIN core.partners pr ON p.partner_id = pr.id + WHERE p.id = $1 AND p.tenant_id = $2 AND p.deleted_at IS NULL`, + [id, tenantId] + ); + + if (!project) { + throw new NotFoundError('Proyecto no encontrado'); + } + + return project; + } + + async create(dto: CreateProjectDto, tenantId: string, userId: string): Promise { + // Check unique code if provided + if (dto.code) { + const existing = await queryOne( + `SELECT id FROM projects.projects WHERE company_id = $1 AND code = $2 AND deleted_at IS NULL`, + [dto.company_id, dto.code] + ); + + if (existing) { + throw new ConflictError('Ya existe un proyecto con ese código'); + } + } + + const project = await queryOne( + `INSERT INTO projects.projects ( + tenant_id, company_id, name, code, description, manager_id, partner_id, + date_start, date_end, privacy, allow_timesheets, color, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING *`, + [ + tenantId, dto.company_id, dto.name, dto.code, dto.description, + dto.manager_id, dto.partner_id, dto.date_start, dto.date_end, + dto.privacy || 'public', dto.allow_timesheets ?? true, dto.color, userId + ] + ); + + return project!; + } + + async update(id: string, dto: UpdateProjectDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.name !== undefined) { + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.code !== undefined) { + if (dto.code) { + const existingCode = await queryOne( + `SELECT id FROM projects.projects WHERE company_id = $1 AND code = $2 AND id != $3 AND deleted_at IS NULL`, + [existing.company_id, dto.code, id] + ); + if (existingCode) { + throw new ConflictError('Ya existe un proyecto con ese código'); + } + } + updateFields.push(`code = $${paramIndex++}`); + values.push(dto.code); + } + if (dto.description !== undefined) { + updateFields.push(`description = $${paramIndex++}`); + values.push(dto.description); + } + if (dto.manager_id !== undefined) { + updateFields.push(`manager_id = $${paramIndex++}`); + values.push(dto.manager_id); + } + if (dto.partner_id !== undefined) { + updateFields.push(`partner_id = $${paramIndex++}`); + values.push(dto.partner_id); + } + if (dto.date_start !== undefined) { + updateFields.push(`date_start = $${paramIndex++}`); + values.push(dto.date_start); + } + if (dto.date_end !== undefined) { + updateFields.push(`date_end = $${paramIndex++}`); + values.push(dto.date_end); + } + if (dto.status !== undefined) { + updateFields.push(`status = $${paramIndex++}`); + values.push(dto.status); + } + if (dto.privacy !== undefined) { + updateFields.push(`privacy = $${paramIndex++}`); + values.push(dto.privacy); + } + if (dto.allow_timesheets !== undefined) { + updateFields.push(`allow_timesheets = $${paramIndex++}`); + values.push(dto.allow_timesheets); + } + if (dto.color !== undefined) { + updateFields.push(`color = $${paramIndex++}`); + values.push(dto.color); + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + await query( + `UPDATE projects.projects SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} AND deleted_at IS NULL`, + values + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string, userId: string): Promise { + await this.findById(id, tenantId); + + // Soft delete + await query( + `UPDATE projects.projects SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $1 + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + } + + async getStats(id: string, tenantId: string): Promise { + await this.findById(id, tenantId); + + const stats = await queryOne<{ + total_tasks: number; + completed_tasks: number; + in_progress_tasks: number; + total_hours: number; + total_milestones: number; + completed_milestones: number; + }>( + `SELECT + (SELECT COUNT(*) FROM projects.tasks WHERE project_id = $1 AND deleted_at IS NULL) as total_tasks, + (SELECT COUNT(*) FROM projects.tasks WHERE project_id = $1 AND status = 'done' AND deleted_at IS NULL) as completed_tasks, + (SELECT COUNT(*) FROM projects.tasks WHERE project_id = $1 AND status = 'in_progress' AND deleted_at IS NULL) as in_progress_tasks, + (SELECT COALESCE(SUM(hours), 0) FROM projects.timesheets WHERE project_id = $1) as total_hours, + (SELECT COUNT(*) FROM projects.milestones WHERE project_id = $1) as total_milestones, + (SELECT COUNT(*) FROM projects.milestones WHERE project_id = $1 AND status = 'completed') as completed_milestones`, + [id] + ); + + return { + total_tasks: parseInt(String(stats?.total_tasks || 0)), + completed_tasks: parseInt(String(stats?.completed_tasks || 0)), + in_progress_tasks: parseInt(String(stats?.in_progress_tasks || 0)), + completion_percentage: stats?.total_tasks + ? Math.round((parseInt(String(stats.completed_tasks)) / parseInt(String(stats.total_tasks))) * 100) + : 0, + total_hours: parseFloat(String(stats?.total_hours || 0)), + total_milestones: parseInt(String(stats?.total_milestones || 0)), + completed_milestones: parseInt(String(stats?.completed_milestones || 0)), + }; + } +} + +export const projectsService = new ProjectsService(); diff --git a/src/modules/projects/tasks.service.ts b/src/modules/projects/tasks.service.ts new file mode 100644 index 0000000..fc47bed --- /dev/null +++ b/src/modules/projects/tasks.service.ts @@ -0,0 +1,293 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; + +export interface Task { + id: string; + tenant_id: string; + project_id: string; + project_name?: string; + stage_id?: string; + stage_name?: string; + name: string; + description?: string; + assigned_to?: string; + assigned_name?: string; + parent_id?: string; + parent_name?: string; + date_deadline?: Date; + estimated_hours?: number; + spent_hours?: number; + priority: 'low' | 'normal' | 'high' | 'urgent'; + status: 'todo' | 'in_progress' | 'review' | 'done' | 'cancelled'; + sequence: number; + color?: string; + created_at: Date; +} + +export interface CreateTaskDto { + project_id: string; + stage_id?: string; + name: string; + description?: string; + assigned_to?: string; + parent_id?: string; + date_deadline?: string; + estimated_hours?: number; + priority?: 'low' | 'normal' | 'high' | 'urgent'; + color?: string; +} + +export interface UpdateTaskDto { + stage_id?: string | null; + name?: string; + description?: string | null; + assigned_to?: string | null; + parent_id?: string | null; + date_deadline?: string | null; + estimated_hours?: number | null; + priority?: 'low' | 'normal' | 'high' | 'urgent'; + status?: 'todo' | 'in_progress' | 'review' | 'done' | 'cancelled'; + sequence?: number; + color?: string | null; +} + +export interface TaskFilters { + project_id?: string; + stage_id?: string; + assigned_to?: string; + status?: string; + priority?: string; + search?: string; + page?: number; + limit?: number; +} + +class TasksService { + async findAll(tenantId: string, filters: TaskFilters = {}): Promise<{ data: Task[]; total: number }> { + const { project_id, stage_id, assigned_to, status, priority, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE t.tenant_id = $1 AND t.deleted_at IS NULL'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (project_id) { + whereClause += ` AND t.project_id = $${paramIndex++}`; + params.push(project_id); + } + + if (stage_id) { + whereClause += ` AND t.stage_id = $${paramIndex++}`; + params.push(stage_id); + } + + if (assigned_to) { + whereClause += ` AND t.assigned_to = $${paramIndex++}`; + params.push(assigned_to); + } + + if (status) { + whereClause += ` AND t.status = $${paramIndex++}`; + params.push(status); + } + + if (priority) { + whereClause += ` AND t.priority = $${paramIndex++}`; + params.push(priority); + } + + if (search) { + whereClause += ` AND t.name ILIKE $${paramIndex}`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM projects.tasks t ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT t.*, + p.name as project_name, + ps.name as stage_name, + u.name as assigned_name, + pt.name as parent_name, + COALESCE((SELECT SUM(hours) FROM projects.timesheets WHERE task_id = t.id), 0) as spent_hours + FROM projects.tasks t + LEFT JOIN projects.projects p ON t.project_id = p.id + LEFT JOIN projects.project_stages ps ON t.stage_id = ps.id + LEFT JOIN auth.users u ON t.assigned_to = u.id + LEFT JOIN projects.tasks pt ON t.parent_id = pt.id + ${whereClause} + ORDER BY t.sequence, t.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const task = await queryOne( + `SELECT t.*, + p.name as project_name, + ps.name as stage_name, + u.name as assigned_name, + pt.name as parent_name, + COALESCE((SELECT SUM(hours) FROM projects.timesheets WHERE task_id = t.id), 0) as spent_hours + FROM projects.tasks t + LEFT JOIN projects.projects p ON t.project_id = p.id + LEFT JOIN projects.project_stages ps ON t.stage_id = ps.id + LEFT JOIN auth.users u ON t.assigned_to = u.id + LEFT JOIN projects.tasks pt ON t.parent_id = pt.id + WHERE t.id = $1 AND t.tenant_id = $2 AND t.deleted_at IS NULL`, + [id, tenantId] + ); + + if (!task) { + throw new NotFoundError('Tarea no encontrada'); + } + + return task; + } + + async create(dto: CreateTaskDto, tenantId: string, userId: string): Promise { + // Get next sequence for project + const seqResult = await queryOne<{ max_seq: number }>( + `SELECT COALESCE(MAX(sequence), 0) + 1 as max_seq FROM projects.tasks WHERE project_id = $1 AND deleted_at IS NULL`, + [dto.project_id] + ); + + const task = await queryOne( + `INSERT INTO projects.tasks ( + tenant_id, project_id, stage_id, name, description, assigned_to, parent_id, + date_deadline, estimated_hours, priority, sequence, color, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING *`, + [ + tenantId, dto.project_id, dto.stage_id, dto.name, dto.description, + dto.assigned_to, dto.parent_id, dto.date_deadline, dto.estimated_hours, + dto.priority || 'normal', seqResult?.max_seq || 1, dto.color, userId + ] + ); + + return task!; + } + + async update(id: string, dto: UpdateTaskDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.stage_id !== undefined) { + updateFields.push(`stage_id = $${paramIndex++}`); + values.push(dto.stage_id); + } + if (dto.name !== undefined) { + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.description !== undefined) { + updateFields.push(`description = $${paramIndex++}`); + values.push(dto.description); + } + if (dto.assigned_to !== undefined) { + updateFields.push(`assigned_to = $${paramIndex++}`); + values.push(dto.assigned_to); + } + if (dto.parent_id !== undefined) { + if (dto.parent_id === id) { + throw new ValidationError('Una tarea no puede ser su propio padre'); + } + updateFields.push(`parent_id = $${paramIndex++}`); + values.push(dto.parent_id); + } + if (dto.date_deadline !== undefined) { + updateFields.push(`date_deadline = $${paramIndex++}`); + values.push(dto.date_deadline); + } + if (dto.estimated_hours !== undefined) { + updateFields.push(`estimated_hours = $${paramIndex++}`); + values.push(dto.estimated_hours); + } + if (dto.priority !== undefined) { + updateFields.push(`priority = $${paramIndex++}`); + values.push(dto.priority); + } + if (dto.status !== undefined) { + updateFields.push(`status = $${paramIndex++}`); + values.push(dto.status); + } + if (dto.sequence !== undefined) { + updateFields.push(`sequence = $${paramIndex++}`); + values.push(dto.sequence); + } + if (dto.color !== undefined) { + updateFields.push(`color = $${paramIndex++}`); + values.push(dto.color); + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + await query( + `UPDATE projects.tasks SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex} AND deleted_at IS NULL`, + values + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string, userId: string): Promise { + await this.findById(id, tenantId); + + // Soft delete + await query( + `UPDATE projects.tasks SET deleted_at = CURRENT_TIMESTAMP, deleted_by = $1 + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + } + + async move(id: string, stageId: string | null, sequence: number, tenantId: string, userId: string): Promise { + const task = await this.findById(id, tenantId); + + await query( + `UPDATE projects.tasks SET stage_id = $1, sequence = $2, updated_by = $3, updated_at = CURRENT_TIMESTAMP + WHERE id = $4 AND tenant_id = $5`, + [stageId, sequence, userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async assign(id: string, userId: string, tenantId: string, currentUserId: string): Promise { + await this.findById(id, tenantId); + + await query( + `UPDATE projects.tasks SET assigned_to = $1, updated_by = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $3 AND tenant_id = $4`, + [userId, currentUserId, id, tenantId] + ); + + return this.findById(id, tenantId); + } +} + +export const tasksService = new TasksService(); diff --git a/src/modules/projects/timesheets.service.ts b/src/modules/projects/timesheets.service.ts new file mode 100644 index 0000000..7a7fe9a --- /dev/null +++ b/src/modules/projects/timesheets.service.ts @@ -0,0 +1,302 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; + +export interface Timesheet { + id: string; + tenant_id: string; + company_id: string; + project_id: string; + project_name?: string; + task_id?: string; + task_name?: string; + user_id: string; + user_name?: string; + date: Date; + hours: number; + description?: string; + billable: boolean; + status: 'draft' | 'submitted' | 'approved' | 'rejected'; + created_at: Date; +} + +export interface CreateTimesheetDto { + company_id: string; + project_id: string; + task_id?: string; + date: string; + hours: number; + description?: string; + billable?: boolean; +} + +export interface UpdateTimesheetDto { + task_id?: string | null; + date?: string; + hours?: number; + description?: string | null; + billable?: boolean; +} + +export interface TimesheetFilters { + company_id?: string; + project_id?: string; + task_id?: string; + user_id?: string; + status?: string; + date_from?: string; + date_to?: string; + page?: number; + limit?: number; +} + +class TimesheetsService { + async findAll(tenantId: string, filters: TimesheetFilters = {}): Promise<{ data: Timesheet[]; total: number }> { + const { company_id, project_id, task_id, user_id, status, date_from, date_to, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE ts.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND ts.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (project_id) { + whereClause += ` AND ts.project_id = $${paramIndex++}`; + params.push(project_id); + } + + if (task_id) { + whereClause += ` AND ts.task_id = $${paramIndex++}`; + params.push(task_id); + } + + if (user_id) { + whereClause += ` AND ts.user_id = $${paramIndex++}`; + params.push(user_id); + } + + if (status) { + whereClause += ` AND ts.status = $${paramIndex++}`; + params.push(status); + } + + if (date_from) { + whereClause += ` AND ts.date >= $${paramIndex++}`; + params.push(date_from); + } + + if (date_to) { + whereClause += ` AND ts.date <= $${paramIndex++}`; + params.push(date_to); + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM projects.timesheets ts ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT ts.*, + p.name as project_name, + t.name as task_name, + u.name as user_name + FROM projects.timesheets ts + LEFT JOIN projects.projects p ON ts.project_id = p.id + LEFT JOIN projects.tasks t ON ts.task_id = t.id + LEFT JOIN auth.users u ON ts.user_id = u.id + ${whereClause} + ORDER BY ts.date DESC, ts.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const timesheet = await queryOne( + `SELECT ts.*, + p.name as project_name, + t.name as task_name, + u.name as user_name + FROM projects.timesheets ts + LEFT JOIN projects.projects p ON ts.project_id = p.id + LEFT JOIN projects.tasks t ON ts.task_id = t.id + LEFT JOIN auth.users u ON ts.user_id = u.id + WHERE ts.id = $1 AND ts.tenant_id = $2`, + [id, tenantId] + ); + + if (!timesheet) { + throw new NotFoundError('Timesheet no encontrado'); + } + + return timesheet; + } + + async create(dto: CreateTimesheetDto, tenantId: string, userId: string): Promise { + if (dto.hours <= 0 || dto.hours > 24) { + throw new ValidationError('Las horas deben estar entre 0 y 24'); + } + + const timesheet = await queryOne( + `INSERT INTO projects.timesheets ( + tenant_id, company_id, project_id, task_id, user_id, date, + hours, description, billable, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [ + tenantId, dto.company_id, dto.project_id, dto.task_id, userId, + dto.date, dto.hours, dto.description, dto.billable ?? true, userId + ] + ); + + return timesheet!; + } + + async update(id: string, dto: UpdateTimesheetDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'draft') { + throw new ValidationError('Solo se pueden editar timesheets en estado borrador'); + } + + if (existing.user_id !== userId) { + throw new ValidationError('Solo puedes editar tus propios timesheets'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.task_id !== undefined) { + updateFields.push(`task_id = $${paramIndex++}`); + values.push(dto.task_id); + } + if (dto.date !== undefined) { + updateFields.push(`date = $${paramIndex++}`); + values.push(dto.date); + } + if (dto.hours !== undefined) { + if (dto.hours <= 0 || dto.hours > 24) { + throw new ValidationError('Las horas deben estar entre 0 y 24'); + } + updateFields.push(`hours = $${paramIndex++}`); + values.push(dto.hours); + } + if (dto.description !== undefined) { + updateFields.push(`description = $${paramIndex++}`); + values.push(dto.description); + } + if (dto.billable !== undefined) { + updateFields.push(`billable = $${paramIndex++}`); + values.push(dto.billable); + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + await query( + `UPDATE projects.timesheets SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'draft') { + throw new ValidationError('Solo se pueden eliminar timesheets en estado borrador'); + } + + if (existing.user_id !== userId) { + throw new ValidationError('Solo puedes eliminar tus propios timesheets'); + } + + await query( + `DELETE FROM projects.timesheets WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + } + + async submit(id: string, tenantId: string, userId: string): Promise { + const timesheet = await this.findById(id, tenantId); + + if (timesheet.status !== 'draft') { + throw new ValidationError('Solo se pueden enviar timesheets en estado borrador'); + } + + if (timesheet.user_id !== userId) { + throw new ValidationError('Solo puedes enviar tus propios timesheets'); + } + + await query( + `UPDATE projects.timesheets SET status = 'submitted', updated_by = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async approve(id: string, tenantId: string, userId: string): Promise { + const timesheet = await this.findById(id, tenantId); + + if (timesheet.status !== 'submitted') { + throw new ValidationError('Solo se pueden aprobar timesheets enviados'); + } + + await query( + `UPDATE projects.timesheets SET status = 'approved', approved_by = $1, approved_at = CURRENT_TIMESTAMP, + updated_by = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async reject(id: string, tenantId: string, userId: string): Promise { + const timesheet = await this.findById(id, tenantId); + + if (timesheet.status !== 'submitted') { + throw new ValidationError('Solo se pueden rechazar timesheets enviados'); + } + + await query( + `UPDATE projects.timesheets SET status = 'rejected', updated_by = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async getMyTimesheets(tenantId: string, userId: string, filters: TimesheetFilters = {}): Promise<{ data: Timesheet[]; total: number }> { + return this.findAll(tenantId, { ...filters, user_id: userId }); + } + + async getPendingApprovals(tenantId: string, filters: TimesheetFilters = {}): Promise<{ data: Timesheet[]; total: number }> { + return this.findAll(tenantId, { ...filters, status: 'submitted' }); + } +} + +export const timesheetsService = new TimesheetsService(); diff --git a/src/modules/purchases/index.ts b/src/modules/purchases/index.ts new file mode 100644 index 0000000..2d12553 --- /dev/null +++ b/src/modules/purchases/index.ts @@ -0,0 +1,4 @@ +export * from './purchases.service.js'; +export * from './rfqs.service.js'; +export * from './purchases.controller.js'; +export { default as purchasesRoutes } from './purchases.routes.js'; diff --git a/src/modules/purchases/purchases.controller.ts b/src/modules/purchases/purchases.controller.ts new file mode 100644 index 0000000..ff3283c --- /dev/null +++ b/src/modules/purchases/purchases.controller.ts @@ -0,0 +1,352 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { purchasesService, CreatePurchaseOrderDto, UpdatePurchaseOrderDto, PurchaseOrderFilters } from './purchases.service.js'; +import { rfqsService, CreateRfqDto, UpdateRfqDto, CreateRfqLineDto, UpdateRfqLineDto, RfqFilters } from './rfqs.service.js'; +import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js'; +import { ValidationError } from '../../shared/errors/index.js'; + +const orderLineSchema = z.object({ + product_id: z.string().uuid(), + description: z.string().min(1), + quantity: z.number().positive(), + uom_id: z.string().uuid(), + price_unit: z.number().min(0), + discount: z.number().min(0).max(100).default(0), + amount_untaxed: z.number().min(0), +}); + +const createOrderSchema = z.object({ + company_id: z.string().uuid(), + name: z.string().min(1).max(100), + ref: z.string().max(100).optional(), + partner_id: z.string().uuid(), + order_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + expected_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + currency_id: z.string().uuid(), + payment_term_id: z.string().uuid().optional(), + notes: z.string().optional(), + lines: z.array(orderLineSchema).min(1), +}); + +const updateOrderSchema = z.object({ + ref: z.string().max(100).optional().nullable(), + partner_id: z.string().uuid().optional(), + order_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + expected_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), + currency_id: z.string().uuid().optional(), + payment_term_id: z.string().uuid().optional().nullable(), + notes: z.string().optional().nullable(), + lines: z.array(orderLineSchema).min(1).optional(), +}); + +const querySchema = z.object({ + company_id: z.string().uuid().optional(), + partner_id: z.string().uuid().optional(), + status: z.enum(['draft', 'sent', 'confirmed', 'done', 'cancelled']).optional(), + date_from: z.string().optional(), + date_to: z.string().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +// ========== RFQ SCHEMAS ========== +const rfqLineSchema = z.object({ + product_id: z.string().uuid().optional(), + description: z.string().min(1), + quantity: z.number().positive(), + uom_id: z.string().uuid(), +}); + +const createRfqSchema = z.object({ + company_id: z.string().uuid(), + partner_ids: z.array(z.string().uuid()).min(1), + request_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + deadline_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + description: z.string().optional(), + notes: z.string().optional(), + lines: z.array(rfqLineSchema).min(1), +}); + +const updateRfqSchema = z.object({ + partner_ids: z.array(z.string().uuid()).min(1).optional(), + deadline_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), + description: z.string().optional().nullable(), + notes: z.string().optional().nullable(), +}); + +const createRfqLineSchema = z.object({ + product_id: z.string().uuid().optional(), + description: z.string().min(1), + quantity: z.number().positive(), + uom_id: z.string().uuid(), +}); + +const updateRfqLineSchema = z.object({ + product_id: z.string().uuid().optional().nullable(), + description: z.string().min(1).optional(), + quantity: z.number().positive().optional(), + uom_id: z.string().uuid().optional(), +}); + +const rfqQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + status: z.enum(['draft', 'sent', 'responded', 'accepted', 'rejected', 'cancelled']).optional(), + date_from: z.string().optional(), + date_to: z.string().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +class PurchasesController { + async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = querySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: PurchaseOrderFilters = queryResult.data; + const result = await purchasesService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + async findById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const order = await purchasesService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: order }); + } catch (error) { + next(error); + } + } + + async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createOrderSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de orden inválidos', parseResult.error.errors); + } + + const dto: CreatePurchaseOrderDto = parseResult.data; + const order = await purchasesService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: order, + message: 'Orden de compra creada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateOrderSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de orden inválidos', parseResult.error.errors); + } + + const dto: UpdatePurchaseOrderDto = parseResult.data; + const order = await purchasesService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: order, + message: 'Orden de compra actualizada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async confirm(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const order = await purchasesService.confirm(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: order, message: 'Orden de compra confirmada exitosamente' }); + } catch (error) { + next(error); + } + } + + async cancel(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const order = await purchasesService.cancel(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: order, message: 'Orden de compra cancelada exitosamente' }); + } catch (error) { + next(error); + } + } + + async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await purchasesService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Orden de compra eliminada exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== RFQs ========== + async getRfqs(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = rfqQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + const filters: RfqFilters = queryResult.data; + const result = await rfqsService.findAll(req.tenantId!, filters); + res.json({ + success: true, + data: result.data, + meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 20)) }, + }); + } catch (error) { + next(error); + } + } + + async getRfq(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const rfq = await rfqsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: rfq }); + } catch (error) { + next(error); + } + } + + async createRfq(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createRfqSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de RFQ inválidos', parseResult.error.errors); + } + const dto: CreateRfqDto = parseResult.data; + const rfq = await rfqsService.create(dto, req.tenantId!, req.user!.userId); + res.status(201).json({ success: true, data: rfq, message: 'Solicitud de cotización creada exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateRfq(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateRfqSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de RFQ inválidos', parseResult.error.errors); + } + const dto: UpdateRfqDto = parseResult.data; + const rfq = await rfqsService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + res.json({ success: true, data: rfq, message: 'Solicitud de cotización actualizada exitosamente' }); + } catch (error) { + next(error); + } + } + + async addRfqLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createRfqLineSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de línea inválidos', parseResult.error.errors); + } + const dto: CreateRfqLineDto = parseResult.data; + const line = await rfqsService.addLine(req.params.id, dto, req.tenantId!); + res.status(201).json({ success: true, data: line, message: 'Línea agregada exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateRfqLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateRfqLineSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de línea inválidos', parseResult.error.errors); + } + const dto: UpdateRfqLineDto = parseResult.data; + const line = await rfqsService.updateLine(req.params.id, req.params.lineId, dto, req.tenantId!); + res.json({ success: true, data: line, message: 'Línea actualizada exitosamente' }); + } catch (error) { + next(error); + } + } + + async removeRfqLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await rfqsService.removeLine(req.params.id, req.params.lineId, req.tenantId!); + res.json({ success: true, message: 'Línea eliminada exitosamente' }); + } catch (error) { + next(error); + } + } + + async sendRfq(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const rfq = await rfqsService.send(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: rfq, message: 'Solicitud enviada exitosamente' }); + } catch (error) { + next(error); + } + } + + async markRfqResponded(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const rfq = await rfqsService.markResponded(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: rfq, message: 'Solicitud marcada como respondida' }); + } catch (error) { + next(error); + } + } + + async acceptRfq(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const rfq = await rfqsService.accept(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: rfq, message: 'Solicitud aceptada exitosamente' }); + } catch (error) { + next(error); + } + } + + async rejectRfq(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const rfq = await rfqsService.reject(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: rfq, message: 'Solicitud rechazada exitosamente' }); + } catch (error) { + next(error); + } + } + + async cancelRfq(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const rfq = await rfqsService.cancel(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: rfq, message: 'Solicitud cancelada exitosamente' }); + } catch (error) { + next(error); + } + } + + async deleteRfq(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await rfqsService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Solicitud eliminada exitosamente' }); + } catch (error) { + next(error); + } + } +} + +export const purchasesController = new PurchasesController(); diff --git a/src/modules/purchases/purchases.routes.ts b/src/modules/purchases/purchases.routes.ts new file mode 100644 index 0000000..64e25df --- /dev/null +++ b/src/modules/purchases/purchases.routes.ts @@ -0,0 +1,90 @@ +import { Router } from 'express'; +import { purchasesController } from './purchases.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// List purchase orders +router.get('/', requireRoles('admin', 'manager', 'warehouse', 'accountant', 'super_admin'), (req, res, next) => + purchasesController.findAll(req, res, next) +); + +// Get purchase order by ID +router.get('/:id', requireRoles('admin', 'manager', 'warehouse', 'accountant', 'super_admin'), (req, res, next) => + purchasesController.findById(req, res, next) +); + +// Create purchase order +router.post('/', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + purchasesController.create(req, res, next) +); + +// Update purchase order +router.put('/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + purchasesController.update(req, res, next) +); + +// Confirm purchase order +router.post('/:id/confirm', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + purchasesController.confirm(req, res, next) +); + +// Cancel purchase order +router.post('/:id/cancel', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + purchasesController.cancel(req, res, next) +); + +// Delete purchase order +router.delete('/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + purchasesController.delete(req, res, next) +); + +// ========== RFQs (Request for Quotation) ========== +router.get('/rfqs', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + purchasesController.getRfqs(req, res, next) +); +router.get('/rfqs/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + purchasesController.getRfq(req, res, next) +); +router.post('/rfqs', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + purchasesController.createRfq(req, res, next) +); +router.put('/rfqs/:id', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + purchasesController.updateRfq(req, res, next) +); +router.delete('/rfqs/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + purchasesController.deleteRfq(req, res, next) +); + +// RFQ Lines +router.post('/rfqs/:id/lines', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + purchasesController.addRfqLine(req, res, next) +); +router.put('/rfqs/:id/lines/:lineId', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + purchasesController.updateRfqLine(req, res, next) +); +router.delete('/rfqs/:id/lines/:lineId', requireRoles('admin', 'manager', 'warehouse', 'super_admin'), (req, res, next) => + purchasesController.removeRfqLine(req, res, next) +); + +// RFQ Workflow +router.post('/rfqs/:id/send', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + purchasesController.sendRfq(req, res, next) +); +router.post('/rfqs/:id/responded', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + purchasesController.markRfqResponded(req, res, next) +); +router.post('/rfqs/:id/accept', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + purchasesController.acceptRfq(req, res, next) +); +router.post('/rfqs/:id/reject', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + purchasesController.rejectRfq(req, res, next) +); +router.post('/rfqs/:id/cancel', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + purchasesController.cancelRfq(req, res, next) +); + +export default router; diff --git a/src/modules/purchases/purchases.service.ts b/src/modules/purchases/purchases.service.ts new file mode 100644 index 0000000..4a59f70 --- /dev/null +++ b/src/modules/purchases/purchases.service.ts @@ -0,0 +1,386 @@ +import { query, queryOne, getClient } from '../../config/database.js'; +import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js'; + +export type OrderStatus = 'draft' | 'sent' | 'confirmed' | 'done' | 'cancelled'; + +export interface PurchaseOrderLine { + id?: string; + product_id: string; + product_name?: string; + product_code?: string; + description: string; + quantity: number; + qty_received?: number; + qty_invoiced?: number; + uom_id: string; + uom_name?: string; + price_unit: number; + discount?: number; + amount_untaxed: number; + amount_tax?: number; + amount_total: number; + expected_date?: string; +} + +export interface PurchaseOrder { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + name: string; + ref?: string; + partner_id: string; + partner_name?: string; + order_date: Date; + expected_date?: Date; + effective_date?: Date; + currency_id: string; + currency_code?: string; + payment_term_id?: string; + amount_untaxed: number; + amount_tax: number; + amount_total: number; + status: OrderStatus; + receipt_status?: string; + invoice_status?: string; + notes?: string; + lines?: PurchaseOrderLine[]; + created_at: Date; + confirmed_at?: Date; +} + +export interface CreatePurchaseOrderDto { + company_id: string; + name: string; + ref?: string; + partner_id: string; + order_date: string; + expected_date?: string; + currency_id: string; + payment_term_id?: string; + notes?: string; + lines: Omit[]; +} + +export interface UpdatePurchaseOrderDto { + ref?: string | null; + partner_id?: string; + order_date?: string; + expected_date?: string | null; + currency_id?: string; + payment_term_id?: string | null; + notes?: string | null; + lines?: Omit[]; +} + +export interface PurchaseOrderFilters { + company_id?: string; + partner_id?: string; + status?: OrderStatus; + date_from?: string; + date_to?: string; + search?: string; + page?: number; + limit?: number; +} + +class PurchasesService { + async findAll(tenantId: string, filters: PurchaseOrderFilters = {}): Promise<{ data: PurchaseOrder[]; total: number }> { + const { company_id, partner_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE po.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND po.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (partner_id) { + whereClause += ` AND po.partner_id = $${paramIndex++}`; + params.push(partner_id); + } + + if (status) { + whereClause += ` AND po.status = $${paramIndex++}`; + params.push(status); + } + + if (date_from) { + whereClause += ` AND po.order_date >= $${paramIndex++}`; + params.push(date_from); + } + + if (date_to) { + whereClause += ` AND po.order_date <= $${paramIndex++}`; + params.push(date_to); + } + + if (search) { + whereClause += ` AND (po.name ILIKE $${paramIndex} OR po.ref ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM purchase.purchase_orders po ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT po.*, + c.name as company_name, + p.name as partner_name, + cur.code as currency_code + FROM purchase.purchase_orders po + LEFT JOIN auth.companies c ON po.company_id = c.id + LEFT JOIN core.partners p ON po.partner_id = p.id + LEFT JOIN core.currencies cur ON po.currency_id = cur.id + ${whereClause} + ORDER BY po.order_date DESC, po.name DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const order = await queryOne( + `SELECT po.*, + c.name as company_name, + p.name as partner_name, + cur.code as currency_code + FROM purchase.purchase_orders po + LEFT JOIN auth.companies c ON po.company_id = c.id + LEFT JOIN core.partners p ON po.partner_id = p.id + LEFT JOIN core.currencies cur ON po.currency_id = cur.id + WHERE po.id = $1 AND po.tenant_id = $2`, + [id, tenantId] + ); + + if (!order) { + throw new NotFoundError('Orden de compra no encontrada'); + } + + // Get lines + const lines = await query( + `SELECT pol.*, + pr.name as product_name, + pr.code as product_code, + u.name as uom_name + FROM purchase.purchase_order_lines pol + LEFT JOIN inventory.products pr ON pol.product_id = pr.id + LEFT JOIN core.uom u ON pol.uom_id = u.id + WHERE pol.order_id = $1 + ORDER BY pol.created_at`, + [id] + ); + + order.lines = lines; + + return order; + } + + async create(dto: CreatePurchaseOrderDto, tenantId: string, userId: string): Promise { + if (dto.lines.length === 0) { + throw new ValidationError('La orden de compra debe tener al menos una línea'); + } + + const client = await getClient(); + + try { + await client.query('BEGIN'); + + // Calculate totals + let amountUntaxed = 0; + for (const line of dto.lines) { + const lineTotal = line.quantity * line.price_unit * (1 - (line.discount || 0) / 100); + amountUntaxed += lineTotal; + } + + // Create order + const orderResult = await client.query( + `INSERT INTO purchase.purchase_orders (tenant_id, company_id, name, ref, partner_id, order_date, expected_date, currency_id, payment_term_id, amount_untaxed, amount_total, notes, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING *`, + [tenantId, dto.company_id, dto.name, dto.ref, dto.partner_id, dto.order_date, dto.expected_date, dto.currency_id, dto.payment_term_id, amountUntaxed, amountUntaxed, dto.notes, userId] + ); + const order = orderResult.rows[0] as PurchaseOrder; + + // Create lines (include tenant_id for multi-tenant security) + for (const line of dto.lines) { + const lineUntaxed = line.quantity * line.price_unit * (1 - (line.discount || 0) / 100); + await client.query( + `INSERT INTO purchase.purchase_order_lines (order_id, tenant_id, product_id, description, quantity, uom_id, price_unit, discount, amount_untaxed, amount_tax, amount_total, expected_date) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10, $11)`, + [order.id, tenantId, line.product_id, line.description, line.quantity, line.uom_id, line.price_unit, line.discount || 0, lineUntaxed, lineUntaxed, dto.expected_date] + ); + } + + await client.query('COMMIT'); + + return this.findById(order.id, tenantId); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async update(id: string, dto: UpdatePurchaseOrderDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'draft') { + throw new ConflictError('Solo se pueden modificar órdenes en estado borrador'); + } + + const client = await getClient(); + + try { + await client.query('BEGIN'); + + // Update order header + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.ref !== undefined) { + updateFields.push(`ref = $${paramIndex++}`); + values.push(dto.ref); + } + if (dto.partner_id !== undefined) { + updateFields.push(`partner_id = $${paramIndex++}`); + values.push(dto.partner_id); + } + if (dto.order_date !== undefined) { + updateFields.push(`order_date = $${paramIndex++}`); + values.push(dto.order_date); + } + if (dto.expected_date !== undefined) { + updateFields.push(`expected_date = $${paramIndex++}`); + values.push(dto.expected_date); + } + if (dto.currency_id !== undefined) { + updateFields.push(`currency_id = $${paramIndex++}`); + values.push(dto.currency_id); + } + if (dto.payment_term_id !== undefined) { + updateFields.push(`payment_term_id = $${paramIndex++}`); + values.push(dto.payment_term_id); + } + if (dto.notes !== undefined) { + updateFields.push(`notes = $${paramIndex++}`); + values.push(dto.notes); + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id); + + if (updateFields.length > 2) { + await client.query( + `UPDATE purchase.purchase_orders SET ${updateFields.join(', ')} WHERE id = $${paramIndex}`, + values + ); + } + + // Update lines if provided + if (dto.lines) { + // Delete existing lines + await client.query(`DELETE FROM purchase.purchase_order_lines WHERE order_id = $1`, [id]); + + // Calculate totals and insert new lines + let amountUntaxed = 0; + for (const line of dto.lines) { + const lineUntaxed = line.quantity * line.price_unit * (1 - (line.discount || 0) / 100); + amountUntaxed += lineUntaxed; + + await client.query( + `INSERT INTO purchase.purchase_order_lines (order_id, tenant_id, product_id, description, quantity, uom_id, price_unit, discount, amount_untaxed, amount_tax, amount_total) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $10)`, + [id, tenantId, line.product_id, line.description, line.quantity, line.uom_id, line.price_unit, line.discount || 0, lineUntaxed, lineUntaxed] + ); + } + + // Update order totals + await client.query( + `UPDATE purchase.purchase_orders SET amount_untaxed = $1, amount_total = $2 WHERE id = $3`, + [amountUntaxed, amountUntaxed, id] + ); + } + + await client.query('COMMIT'); + + return this.findById(id, tenantId); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async confirm(id: string, tenantId: string, userId: string): Promise { + const order = await this.findById(id, tenantId); + + if (order.status !== 'draft') { + throw new ConflictError('Solo se pueden confirmar órdenes en estado borrador'); + } + + if (!order.lines || order.lines.length === 0) { + throw new ValidationError('La orden debe tener al menos una línea para confirmar'); + } + + await query( + `UPDATE purchase.purchase_orders + SET status = 'confirmed', confirmed_at = CURRENT_TIMESTAMP, confirmed_by = $1, updated_at = CURRENT_TIMESTAMP, updated_by = $1 + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async cancel(id: string, tenantId: string, userId: string): Promise { + const order = await this.findById(id, tenantId); + + if (order.status === 'cancelled') { + throw new ConflictError('La orden ya está cancelada'); + } + + if (order.status === 'done') { + throw new ConflictError('No se puede cancelar una orden completada'); + } + + await query( + `UPDATE purchase.purchase_orders + SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, cancelled_by = $1, updated_at = CURRENT_TIMESTAMP, updated_by = $1 + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + const order = await this.findById(id, tenantId); + + if (order.status !== 'draft') { + throw new ConflictError('Solo se pueden eliminar órdenes en estado borrador'); + } + + await query(`DELETE FROM purchase.purchase_orders WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } +} + +export const purchasesService = new PurchasesService(); diff --git a/src/modules/purchases/rfqs.service.ts b/src/modules/purchases/rfqs.service.ts new file mode 100644 index 0000000..8c2e72d --- /dev/null +++ b/src/modules/purchases/rfqs.service.ts @@ -0,0 +1,485 @@ +import { query, queryOne, getClient } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; + +export type RfqStatus = 'draft' | 'sent' | 'responded' | 'accepted' | 'rejected' | 'cancelled'; + +export interface RfqLine { + id: string; + rfq_id: string; + product_id?: string; + product_name?: string; + product_code?: string; + description: string; + quantity: number; + uom_id: string; + uom_name?: string; + created_at: Date; +} + +export interface Rfq { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + name: string; + partner_ids: string[]; + partner_names?: string[]; + request_date: Date; + deadline_date?: Date; + response_date?: Date; + status: RfqStatus; + description?: string; + notes?: string; + lines?: RfqLine[]; + created_at: Date; +} + +export interface CreateRfqLineDto { + product_id?: string; + description: string; + quantity: number; + uom_id: string; +} + +export interface CreateRfqDto { + company_id: string; + partner_ids: string[]; + request_date?: string; + deadline_date?: string; + description?: string; + notes?: string; + lines: CreateRfqLineDto[]; +} + +export interface UpdateRfqDto { + partner_ids?: string[]; + deadline_date?: string | null; + description?: string | null; + notes?: string | null; +} + +export interface UpdateRfqLineDto { + product_id?: string | null; + description?: string; + quantity?: number; + uom_id?: string; +} + +export interface RfqFilters { + company_id?: string; + status?: RfqStatus; + date_from?: string; + date_to?: string; + search?: string; + page?: number; + limit?: number; +} + +class RfqsService { + async findAll(tenantId: string, filters: RfqFilters = {}): Promise<{ data: Rfq[]; total: number }> { + const { company_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE r.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND r.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (status) { + whereClause += ` AND r.status = $${paramIndex++}`; + params.push(status); + } + + if (date_from) { + whereClause += ` AND r.request_date >= $${paramIndex++}`; + params.push(date_from); + } + + if (date_to) { + whereClause += ` AND r.request_date <= $${paramIndex++}`; + params.push(date_to); + } + + if (search) { + whereClause += ` AND (r.name ILIKE $${paramIndex} OR r.description ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM purchase.rfqs r ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT r.*, + c.name as company_name + FROM purchase.rfqs r + LEFT JOIN auth.companies c ON r.company_id = c.id + ${whereClause} + ORDER BY r.request_date DESC, r.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const rfq = await queryOne( + `SELECT r.*, + c.name as company_name + FROM purchase.rfqs r + LEFT JOIN auth.companies c ON r.company_id = c.id + WHERE r.id = $1 AND r.tenant_id = $2`, + [id, tenantId] + ); + + if (!rfq) { + throw new NotFoundError('Solicitud de cotización no encontrada'); + } + + // Get partner names + if (rfq.partner_ids && rfq.partner_ids.length > 0) { + const partners = await query<{ id: string; name: string }>( + `SELECT id, name FROM core.partners WHERE id = ANY($1)`, + [rfq.partner_ids] + ); + rfq.partner_names = partners.map(p => p.name); + } + + // Get lines + const lines = await query( + `SELECT rl.*, + pr.name as product_name, + pr.code as product_code, + u.name as uom_name + FROM purchase.rfq_lines rl + LEFT JOIN inventory.products pr ON rl.product_id = pr.id + LEFT JOIN core.uom u ON rl.uom_id = u.id + WHERE rl.rfq_id = $1 + ORDER BY rl.created_at`, + [id] + ); + + rfq.lines = lines; + + return rfq; + } + + async create(dto: CreateRfqDto, tenantId: string, userId: string): Promise { + if (dto.lines.length === 0) { + throw new ValidationError('La solicitud debe tener al menos una línea'); + } + + if (dto.partner_ids.length === 0) { + throw new ValidationError('Debe especificar al menos un proveedor'); + } + + const client = await getClient(); + + try { + await client.query('BEGIN'); + + // Generate RFQ name + const seqResult = await client.query( + `SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 5) AS INTEGER)), 0) + 1 as next_num + FROM purchase.rfqs WHERE tenant_id = $1 AND name LIKE 'RFQ-%'`, + [tenantId] + ); + const nextNum = seqResult.rows[0]?.next_num || 1; + const rfqName = `RFQ-${String(nextNum).padStart(6, '0')}`; + + const requestDate = dto.request_date || new Date().toISOString().split('T')[0]; + + // Create RFQ + const rfqResult = await client.query( + `INSERT INTO purchase.rfqs ( + tenant_id, company_id, name, partner_ids, request_date, deadline_date, + description, notes, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING *`, + [ + tenantId, dto.company_id, rfqName, dto.partner_ids, requestDate, + dto.deadline_date, dto.description, dto.notes, userId + ] + ); + const rfq = rfqResult.rows[0]; + + // Create lines + for (const line of dto.lines) { + await client.query( + `INSERT INTO purchase.rfq_lines (rfq_id, tenant_id, product_id, description, quantity, uom_id) + VALUES ($1, $2, $3, $4, $5, $6)`, + [rfq.id, tenantId, line.product_id, line.description, line.quantity, line.uom_id] + ); + } + + await client.query('COMMIT'); + + return this.findById(rfq.id, tenantId); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async update(id: string, dto: UpdateRfqDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'draft') { + throw new ValidationError('Solo se pueden editar solicitudes en estado borrador'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.partner_ids !== undefined) { + updateFields.push(`partner_ids = $${paramIndex++}`); + values.push(dto.partner_ids); + } + if (dto.deadline_date !== undefined) { + updateFields.push(`deadline_date = $${paramIndex++}`); + values.push(dto.deadline_date); + } + if (dto.description !== undefined) { + updateFields.push(`description = $${paramIndex++}`); + values.push(dto.description); + } + if (dto.notes !== undefined) { + updateFields.push(`notes = $${paramIndex++}`); + values.push(dto.notes); + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + await query( + `UPDATE purchase.rfqs SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async addLine(rfqId: string, dto: CreateRfqLineDto, tenantId: string): Promise { + const rfq = await this.findById(rfqId, tenantId); + + if (rfq.status !== 'draft') { + throw new ValidationError('Solo se pueden agregar líneas a solicitudes en estado borrador'); + } + + const line = await queryOne( + `INSERT INTO purchase.rfq_lines (rfq_id, tenant_id, product_id, description, quantity, uom_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [rfqId, tenantId, dto.product_id, dto.description, dto.quantity, dto.uom_id] + ); + + return line!; + } + + async updateLine(rfqId: string, lineId: string, dto: UpdateRfqLineDto, tenantId: string): Promise { + const rfq = await this.findById(rfqId, tenantId); + + if (rfq.status !== 'draft') { + throw new ValidationError('Solo se pueden editar líneas en solicitudes en estado borrador'); + } + + const existingLine = rfq.lines?.find(l => l.id === lineId); + if (!existingLine) { + throw new NotFoundError('Línea no encontrada'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.product_id !== undefined) { + updateFields.push(`product_id = $${paramIndex++}`); + values.push(dto.product_id); + } + if (dto.description !== undefined) { + updateFields.push(`description = $${paramIndex++}`); + values.push(dto.description); + } + if (dto.quantity !== undefined) { + updateFields.push(`quantity = $${paramIndex++}`); + values.push(dto.quantity); + } + if (dto.uom_id !== undefined) { + updateFields.push(`uom_id = $${paramIndex++}`); + values.push(dto.uom_id); + } + + if (updateFields.length === 0) { + return existingLine; + } + + values.push(lineId); + + const line = await queryOne( + `UPDATE purchase.rfq_lines SET ${updateFields.join(', ')} + WHERE id = $${paramIndex} + RETURNING *`, + values + ); + + return line!; + } + + async removeLine(rfqId: string, lineId: string, tenantId: string): Promise { + const rfq = await this.findById(rfqId, tenantId); + + if (rfq.status !== 'draft') { + throw new ValidationError('Solo se pueden eliminar líneas en solicitudes en estado borrador'); + } + + const existingLine = rfq.lines?.find(l => l.id === lineId); + if (!existingLine) { + throw new NotFoundError('Línea no encontrada'); + } + + if (rfq.lines && rfq.lines.length <= 1) { + throw new ValidationError('La solicitud debe tener al menos una línea'); + } + + await query(`DELETE FROM purchase.rfq_lines WHERE id = $1`, [lineId]); + } + + async send(id: string, tenantId: string, userId: string): Promise { + const rfq = await this.findById(id, tenantId); + + if (rfq.status !== 'draft') { + throw new ValidationError('Solo se pueden enviar solicitudes en estado borrador'); + } + + if (!rfq.lines || rfq.lines.length === 0) { + throw new ValidationError('La solicitud debe tener al menos una línea'); + } + + await query( + `UPDATE purchase.rfqs SET + status = 'sent', + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async markResponded(id: string, tenantId: string, userId: string): Promise { + const rfq = await this.findById(id, tenantId); + + if (rfq.status !== 'sent') { + throw new ValidationError('Solo se pueden marcar como respondidas solicitudes enviadas'); + } + + await query( + `UPDATE purchase.rfqs SET + status = 'responded', + response_date = CURRENT_DATE, + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async accept(id: string, tenantId: string, userId: string): Promise { + const rfq = await this.findById(id, tenantId); + + if (rfq.status !== 'responded' && rfq.status !== 'sent') { + throw new ValidationError('Solo se pueden aceptar solicitudes enviadas o respondidas'); + } + + await query( + `UPDATE purchase.rfqs SET + status = 'accepted', + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async reject(id: string, tenantId: string, userId: string): Promise { + const rfq = await this.findById(id, tenantId); + + if (rfq.status !== 'responded' && rfq.status !== 'sent') { + throw new ValidationError('Solo se pueden rechazar solicitudes enviadas o respondidas'); + } + + await query( + `UPDATE purchase.rfqs SET + status = 'rejected', + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async cancel(id: string, tenantId: string, userId: string): Promise { + const rfq = await this.findById(id, tenantId); + + if (rfq.status === 'cancelled') { + throw new ValidationError('La solicitud ya está cancelada'); + } + + if (rfq.status === 'accepted') { + throw new ValidationError('No se puede cancelar una solicitud aceptada'); + } + + await query( + `UPDATE purchase.rfqs SET + status = 'cancelled', + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + const rfq = await this.findById(id, tenantId); + + if (rfq.status !== 'draft') { + throw new ValidationError('Solo se pueden eliminar solicitudes en estado borrador'); + } + + await query(`DELETE FROM purchase.rfqs WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } +} + +export const rfqsService = new RfqsService(); diff --git a/src/modules/reports/index.ts b/src/modules/reports/index.ts new file mode 100644 index 0000000..b5d3f41 --- /dev/null +++ b/src/modules/reports/index.ts @@ -0,0 +1,3 @@ +export * from './reports.service.js'; +export * from './reports.controller.js'; +export { default as reportsRoutes } from './reports.routes.js'; diff --git a/src/modules/reports/reports.controller.ts b/src/modules/reports/reports.controller.ts new file mode 100644 index 0000000..42e0286 --- /dev/null +++ b/src/modules/reports/reports.controller.ts @@ -0,0 +1,434 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { AuthenticatedRequest } from '../../shared/types/index.js'; +import { reportsService } from './reports.service.js'; + +// ============================================================================ +// VALIDATION SCHEMAS +// ============================================================================ + +const reportFiltersSchema = z.object({ + report_type: z.enum(['financial', 'accounting', 'tax', 'management', 'custom']).optional(), + category: z.string().optional(), + is_system: z.coerce.boolean().optional(), + search: z.string().optional(), + page: z.coerce.number().min(1).default(1), + limit: z.coerce.number().min(1).max(100).default(20), +}); + +const createDefinitionSchema = z.object({ + code: z.string().min(1).max(50), + name: z.string().min(1).max(255), + description: z.string().optional(), + report_type: z.enum(['financial', 'accounting', 'tax', 'management', 'custom']).optional(), + category: z.string().optional(), + base_query: z.string().optional(), + query_function: z.string().optional(), + parameters_schema: z.record(z.any()).optional(), + columns_config: z.array(z.any()).optional(), + export_formats: z.array(z.string()).optional(), + required_permissions: z.array(z.string()).optional(), +}); + +const executeReportSchema = z.object({ + definition_id: z.string().uuid(), + parameters: z.record(z.any()), +}); + +const createScheduleSchema = z.object({ + definition_id: z.string().uuid(), + name: z.string().min(1).max(255), + cron_expression: z.string().min(1), + default_parameters: z.record(z.any()).optional(), + company_id: z.string().uuid().optional(), + timezone: z.string().optional(), + delivery_method: z.enum(['none', 'email', 'storage', 'webhook']).optional(), + delivery_config: z.record(z.any()).optional(), +}); + +const trialBalanceSchema = z.object({ + company_id: z.string().uuid().optional(), + date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + include_zero: z.coerce.boolean().optional(), +}); + +const generalLedgerSchema = z.object({ + company_id: z.string().uuid().optional(), + account_id: z.string().uuid(), + date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), +}); + +// ============================================================================ +// CONTROLLER +// ============================================================================ + +class ReportsController { + // ==================== DEFINITIONS ==================== + + /** + * GET /reports/definitions + * List all report definitions + */ + async findAllDefinitions( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const filters = reportFiltersSchema.parse(req.query); + const tenantId = req.user!.tenantId; + + const { data, total } = await reportsService.findAllDefinitions(tenantId, filters); + + res.json({ + success: true, + data, + pagination: { + page: filters.page, + limit: filters.limit, + total, + totalPages: Math.ceil(total / filters.limit), + }, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/definitions/:id + * Get a specific report definition + */ + async findDefinitionById( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { id } = req.params; + const tenantId = req.user!.tenantId; + + const definition = await reportsService.findDefinitionById(id, tenantId); + + res.json({ + success: true, + data: definition, + }); + } catch (error) { + next(error); + } + } + + /** + * POST /reports/definitions + * Create a custom report definition + */ + async createDefinition( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const dto = createDefinitionSchema.parse(req.body); + const tenantId = req.user!.tenantId; + const userId = req.user!.userId; + + const definition = await reportsService.createDefinition(dto, tenantId, userId); + + res.status(201).json({ + success: true, + message: 'Definición de reporte creada exitosamente', + data: definition, + }); + } catch (error) { + next(error); + } + } + + // ==================== EXECUTIONS ==================== + + /** + * POST /reports/execute + * Execute a report + */ + async executeReport( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const dto = executeReportSchema.parse(req.body); + const tenantId = req.user!.tenantId; + const userId = req.user!.userId; + + const execution = await reportsService.executeReport(dto, tenantId, userId); + + res.status(202).json({ + success: true, + message: 'Reporte en ejecución', + data: execution, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/executions/:id + * Get execution details and results + */ + async findExecutionById( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { id } = req.params; + const tenantId = req.user!.tenantId; + + const execution = await reportsService.findExecutionById(id, tenantId); + + res.json({ + success: true, + data: execution, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/executions + * Get recent executions + */ + async findRecentExecutions( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { definition_id, limit } = req.query; + const tenantId = req.user!.tenantId; + + const executions = await reportsService.findRecentExecutions( + tenantId, + definition_id as string, + parseInt(limit as string) || 20 + ); + + res.json({ + success: true, + data: executions, + }); + } catch (error) { + next(error); + } + } + + // ==================== SCHEDULES ==================== + + /** + * GET /reports/schedules + * List all schedules + */ + async findAllSchedules( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const tenantId = req.user!.tenantId; + const schedules = await reportsService.findAllSchedules(tenantId); + + res.json({ + success: true, + data: schedules, + }); + } catch (error) { + next(error); + } + } + + /** + * POST /reports/schedules + * Create a schedule + */ + async createSchedule( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const dto = createScheduleSchema.parse(req.body); + const tenantId = req.user!.tenantId; + const userId = req.user!.userId; + + const schedule = await reportsService.createSchedule(dto, tenantId, userId); + + res.status(201).json({ + success: true, + message: 'Programación creada exitosamente', + data: schedule, + }); + } catch (error) { + next(error); + } + } + + /** + * PATCH /reports/schedules/:id/toggle + * Enable/disable a schedule + */ + async toggleSchedule( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { id } = req.params; + const { is_active } = req.body; + const tenantId = req.user!.tenantId; + + const schedule = await reportsService.toggleSchedule(id, tenantId, is_active); + + res.json({ + success: true, + message: is_active ? 'Programación activada' : 'Programación desactivada', + data: schedule, + }); + } catch (error) { + next(error); + } + } + + /** + * DELETE /reports/schedules/:id + * Delete a schedule + */ + async deleteSchedule( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const { id } = req.params; + const tenantId = req.user!.tenantId; + + await reportsService.deleteSchedule(id, tenantId); + + res.json({ + success: true, + message: 'Programación eliminada', + }); + } catch (error) { + next(error); + } + } + + // ==================== QUICK REPORTS ==================== + + /** + * GET /reports/quick/trial-balance + * Generate trial balance directly + */ + async getTrialBalance( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const params = trialBalanceSchema.parse(req.query); + const tenantId = req.user!.tenantId; + + const data = await reportsService.generateTrialBalance( + tenantId, + params.company_id || null, + params.date_from, + params.date_to, + params.include_zero || false + ); + + // Calculate totals + const totals = { + initial_debit: 0, + initial_credit: 0, + period_debit: 0, + period_credit: 0, + final_debit: 0, + final_credit: 0, + }; + + for (const row of data) { + totals.initial_debit += parseFloat(row.initial_debit) || 0; + totals.initial_credit += parseFloat(row.initial_credit) || 0; + totals.period_debit += parseFloat(row.period_debit) || 0; + totals.period_credit += parseFloat(row.period_credit) || 0; + totals.final_debit += parseFloat(row.final_debit) || 0; + totals.final_credit += parseFloat(row.final_credit) || 0; + } + + res.json({ + success: true, + data, + summary: { + row_count: data.length, + totals, + }, + parameters: params, + }); + } catch (error) { + next(error); + } + } + + /** + * GET /reports/quick/general-ledger + * Generate general ledger directly + */ + async getGeneralLedger( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise { + try { + const params = generalLedgerSchema.parse(req.query); + const tenantId = req.user!.tenantId; + + const data = await reportsService.generateGeneralLedger( + tenantId, + params.company_id || null, + params.account_id, + params.date_from, + params.date_to + ); + + // Calculate totals + const totals = { + debit: 0, + credit: 0, + }; + + for (const row of data) { + totals.debit += parseFloat(row.debit) || 0; + totals.credit += parseFloat(row.credit) || 0; + } + + res.json({ + success: true, + data, + summary: { + row_count: data.length, + totals, + final_balance: data.length > 0 ? data[data.length - 1].running_balance : 0, + }, + parameters: params, + }); + } catch (error) { + next(error); + } + } +} + +export const reportsController = new ReportsController(); diff --git a/src/modules/reports/reports.routes.ts b/src/modules/reports/reports.routes.ts new file mode 100644 index 0000000..fa3c71e --- /dev/null +++ b/src/modules/reports/reports.routes.ts @@ -0,0 +1,96 @@ +import { Router } from 'express'; +import { reportsController } from './reports.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// ============================================================================ +// QUICK REPORTS (direct access without execution record) +// ============================================================================ + +router.get('/quick/trial-balance', + requireRoles('admin', 'manager', 'accountant', 'super_admin'), + (req, res, next) => reportsController.getTrialBalance(req, res, next) +); + +router.get('/quick/general-ledger', + requireRoles('admin', 'manager', 'accountant', 'super_admin'), + (req, res, next) => reportsController.getGeneralLedger(req, res, next) +); + +// ============================================================================ +// DEFINITIONS +// ============================================================================ + +// List all report definitions +router.get('/definitions', + requireRoles('admin', 'manager', 'accountant', 'super_admin'), + (req, res, next) => reportsController.findAllDefinitions(req, res, next) +); + +// Get specific definition +router.get('/definitions/:id', + requireRoles('admin', 'manager', 'accountant', 'super_admin'), + (req, res, next) => reportsController.findDefinitionById(req, res, next) +); + +// Create custom definition (admin only) +router.post('/definitions', + requireRoles('admin', 'super_admin'), + (req, res, next) => reportsController.createDefinition(req, res, next) +); + +// ============================================================================ +// EXECUTIONS +// ============================================================================ + +// Execute a report +router.post('/execute', + requireRoles('admin', 'manager', 'accountant', 'super_admin'), + (req, res, next) => reportsController.executeReport(req, res, next) +); + +// Get recent executions +router.get('/executions', + requireRoles('admin', 'manager', 'accountant', 'super_admin'), + (req, res, next) => reportsController.findRecentExecutions(req, res, next) +); + +// Get specific execution +router.get('/executions/:id', + requireRoles('admin', 'manager', 'accountant', 'super_admin'), + (req, res, next) => reportsController.findExecutionById(req, res, next) +); + +// ============================================================================ +// SCHEDULES +// ============================================================================ + +// List schedules +router.get('/schedules', + requireRoles('admin', 'manager', 'super_admin'), + (req, res, next) => reportsController.findAllSchedules(req, res, next) +); + +// Create schedule +router.post('/schedules', + requireRoles('admin', 'super_admin'), + (req, res, next) => reportsController.createSchedule(req, res, next) +); + +// Toggle schedule +router.patch('/schedules/:id/toggle', + requireRoles('admin', 'super_admin'), + (req, res, next) => reportsController.toggleSchedule(req, res, next) +); + +// Delete schedule +router.delete('/schedules/:id', + requireRoles('admin', 'super_admin'), + (req, res, next) => reportsController.deleteSchedule(req, res, next) +); + +export default router; diff --git a/src/modules/reports/reports.service.ts b/src/modules/reports/reports.service.ts new file mode 100644 index 0000000..717af87 --- /dev/null +++ b/src/modules/reports/reports.service.ts @@ -0,0 +1,580 @@ +import { query, queryOne, getClient } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export type ReportType = 'financial' | 'accounting' | 'tax' | 'management' | 'custom'; +export type ExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; +export type DeliveryMethod = 'none' | 'email' | 'storage' | 'webhook'; + +export interface ReportDefinition { + id: string; + tenant_id: string; + code: string; + name: string; + description: string | null; + report_type: ReportType; + category: string | null; + base_query: string | null; + query_function: string | null; + parameters_schema: Record; + columns_config: any[]; + grouping_options: string[]; + totals_config: Record; + export_formats: string[]; + pdf_template: string | null; + xlsx_template: string | null; + is_system: boolean; + is_active: boolean; + required_permissions: string[]; + version: number; + created_at: Date; +} + +export interface ReportExecution { + id: string; + tenant_id: string; + definition_id: string; + definition_name?: string; + definition_code?: string; + parameters: Record; + status: ExecutionStatus; + started_at: Date | null; + completed_at: Date | null; + execution_time_ms: number | null; + row_count: number | null; + result_data: any; + result_summary: Record | null; + output_files: any[]; + error_message: string | null; + error_details: Record | null; + requested_by: string; + requested_by_name?: string; + created_at: Date; +} + +export interface ReportSchedule { + id: string; + tenant_id: string; + definition_id: string; + definition_name?: string; + company_id: string | null; + name: string; + default_parameters: Record; + cron_expression: string; + timezone: string; + is_active: boolean; + last_execution_id: string | null; + last_run_at: Date | null; + next_run_at: Date | null; + delivery_method: DeliveryMethod; + delivery_config: Record; + created_at: Date; +} + +export interface CreateReportDefinitionDto { + code: string; + name: string; + description?: string; + report_type?: ReportType; + category?: string; + base_query?: string; + query_function?: string; + parameters_schema?: Record; + columns_config?: any[]; + export_formats?: string[]; + required_permissions?: string[]; +} + +export interface ExecuteReportDto { + definition_id: string; + parameters: Record; +} + +export interface ReportFilters { + report_type?: ReportType; + category?: string; + is_system?: boolean; + search?: string; + page?: number; + limit?: number; +} + +// ============================================================================ +// SERVICE +// ============================================================================ + +class ReportsService { + // ==================== DEFINITIONS ==================== + + async findAllDefinitions( + tenantId: string, + filters: ReportFilters = {} + ): Promise<{ data: ReportDefinition[]; total: number }> { + const { report_type, category, is_system, search, page = 1, limit = 20 } = filters; + const conditions: string[] = ['tenant_id = $1', 'is_active = true']; + const params: any[] = [tenantId]; + let idx = 2; + + if (report_type) { + conditions.push(`report_type = $${idx++}`); + params.push(report_type); + } + + if (category) { + conditions.push(`category = $${idx++}`); + params.push(category); + } + + if (is_system !== undefined) { + conditions.push(`is_system = $${idx++}`); + params.push(is_system); + } + + if (search) { + conditions.push(`(name ILIKE $${idx} OR code ILIKE $${idx} OR description ILIKE $${idx})`); + params.push(`%${search}%`); + idx++; + } + + const whereClause = conditions.join(' AND '); + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM reports.report_definitions WHERE ${whereClause}`, + params + ); + + const offset = (page - 1) * limit; + params.push(limit, offset); + + const data = await query( + `SELECT * FROM reports.report_definitions + WHERE ${whereClause} + ORDER BY is_system DESC, name ASC + LIMIT $${idx} OFFSET $${idx + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findDefinitionById(id: string, tenantId: string): Promise { + const definition = await queryOne( + `SELECT * FROM reports.report_definitions WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + if (!definition) { + throw new NotFoundError('Definición de reporte no encontrada'); + } + + return definition; + } + + async findDefinitionByCode(code: string, tenantId: string): Promise { + return queryOne( + `SELECT * FROM reports.report_definitions WHERE code = $1 AND tenant_id = $2`, + [code, tenantId] + ); + } + + async createDefinition( + dto: CreateReportDefinitionDto, + tenantId: string, + userId: string + ): Promise { + const definition = await queryOne( + `INSERT INTO reports.report_definitions ( + tenant_id, code, name, description, report_type, category, + base_query, query_function, parameters_schema, columns_config, + export_formats, required_permissions, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING *`, + [ + tenantId, + dto.code, + dto.name, + dto.description || null, + dto.report_type || 'custom', + dto.category || null, + dto.base_query || null, + dto.query_function || null, + JSON.stringify(dto.parameters_schema || {}), + JSON.stringify(dto.columns_config || []), + JSON.stringify(dto.export_formats || ['pdf', 'xlsx', 'csv']), + JSON.stringify(dto.required_permissions || []), + userId, + ] + ); + + logger.info('Report definition created', { definitionId: definition?.id, code: dto.code }); + + return definition!; + } + + // ==================== EXECUTIONS ==================== + + async executeReport( + dto: ExecuteReportDto, + tenantId: string, + userId: string + ): Promise { + const definition = await this.findDefinitionById(dto.definition_id, tenantId); + + // Validar parámetros contra el schema + this.validateParameters(dto.parameters, definition.parameters_schema); + + // Crear registro de ejecución + const execution = await queryOne( + `INSERT INTO reports.report_executions ( + tenant_id, definition_id, parameters, status, requested_by + ) VALUES ($1, $2, $3, 'pending', $4) + RETURNING *`, + [tenantId, dto.definition_id, JSON.stringify(dto.parameters), userId] + ); + + // Ejecutar el reporte de forma asíncrona + this.runReportExecution(execution!.id, definition, dto.parameters, tenantId) + .catch(err => logger.error('Report execution failed', { executionId: execution!.id, error: err })); + + return execution!; + } + + private async runReportExecution( + executionId: string, + definition: ReportDefinition, + parameters: Record, + tenantId: string + ): Promise { + const startTime = Date.now(); + + try { + // Marcar como ejecutando + await query( + `UPDATE reports.report_executions SET status = 'running', started_at = NOW() WHERE id = $1`, + [executionId] + ); + + let resultData: any; + let rowCount = 0; + + if (definition.query_function) { + // Ejecutar función PostgreSQL + const funcParams = this.buildFunctionParams(definition.query_function, parameters, tenantId); + resultData = await query( + `SELECT * FROM ${definition.query_function}(${funcParams.placeholders})`, + funcParams.values + ); + rowCount = resultData.length; + } else if (definition.base_query) { + // Ejecutar query base con parámetros sustituidos + // IMPORTANTE: Sanitizar los parámetros para evitar SQL injection + const sanitizedQuery = this.buildSafeQuery(definition.base_query, parameters, tenantId); + resultData = await query(sanitizedQuery.sql, sanitizedQuery.values); + rowCount = resultData.length; + } else { + throw new Error('La definición del reporte no tiene query ni función definida'); + } + + const executionTime = Date.now() - startTime; + + // Calcular resumen si hay config de totales + const resultSummary = this.calculateSummary(resultData, definition.totals_config); + + // Actualizar con resultados + await query( + `UPDATE reports.report_executions + SET status = 'completed', + completed_at = NOW(), + execution_time_ms = $2, + row_count = $3, + result_data = $4, + result_summary = $5 + WHERE id = $1`, + [executionId, executionTime, rowCount, JSON.stringify(resultData), JSON.stringify(resultSummary)] + ); + + logger.info('Report execution completed', { executionId, rowCount, executionTime }); + + } catch (error: any) { + const executionTime = Date.now() - startTime; + + await query( + `UPDATE reports.report_executions + SET status = 'failed', + completed_at = NOW(), + execution_time_ms = $2, + error_message = $3, + error_details = $4 + WHERE id = $1`, + [ + executionId, + executionTime, + error.message, + JSON.stringify({ stack: error.stack }), + ] + ); + + logger.error('Report execution failed', { executionId, error: error.message }); + } + } + + private buildFunctionParams( + functionName: string, + parameters: Record, + tenantId: string + ): { placeholders: string; values: any[] } { + // Construir parámetros para funciones conocidas + const values: any[] = [tenantId]; + let idx = 2; + + if (functionName.includes('trial_balance')) { + values.push( + parameters.company_id || null, + parameters.date_from, + parameters.date_to, + parameters.include_zero || false + ); + return { placeholders: '$1, $2, $3, $4, $5', values }; + } + + if (functionName.includes('general_ledger')) { + values.push( + parameters.company_id || null, + parameters.account_id, + parameters.date_from, + parameters.date_to + ); + return { placeholders: '$1, $2, $3, $4, $5', values }; + } + + // Default: solo tenant_id + return { placeholders: '$1', values }; + } + + private buildSafeQuery( + baseQuery: string, + parameters: Record, + tenantId: string + ): { sql: string; values: any[] } { + // Reemplazar placeholders de forma segura + let sql = baseQuery; + const values: any[] = [tenantId]; + let idx = 2; + + // Reemplazar {{tenant_id}} con $1 + sql = sql.replace(/\{\{tenant_id\}\}/g, '$1'); + + // Reemplazar otros parámetros + for (const [key, value] of Object.entries(parameters)) { + const placeholder = `{{${key}}}`; + if (sql.includes(placeholder)) { + sql = sql.replace(new RegExp(placeholder, 'g'), `$${idx}`); + values.push(value); + idx++; + } + } + + return { sql, values }; + } + + private calculateSummary(data: any[], totalsConfig: Record): Record { + if (!totalsConfig.show_totals || !totalsConfig.total_columns) { + return {}; + } + + const summary: Record = {}; + + for (const column of totalsConfig.total_columns) { + summary[column] = data.reduce((sum, row) => sum + (parseFloat(row[column]) || 0), 0); + } + + return summary; + } + + private validateParameters(params: Record, schema: Record): void { + for (const [key, config] of Object.entries(schema)) { + const paramConfig = config as { required?: boolean; type?: string }; + + if (paramConfig.required && (params[key] === undefined || params[key] === null)) { + throw new ValidationError(`Parámetro requerido: ${key}`); + } + } + } + + async findExecutionById(id: string, tenantId: string): Promise { + const execution = await queryOne( + `SELECT re.*, + rd.name as definition_name, + rd.code as definition_code, + u.full_name as requested_by_name + FROM reports.report_executions re + JOIN reports.report_definitions rd ON re.definition_id = rd.id + JOIN auth.users u ON re.requested_by = u.id + WHERE re.id = $1 AND re.tenant_id = $2`, + [id, tenantId] + ); + + if (!execution) { + throw new NotFoundError('Ejecución de reporte no encontrada'); + } + + return execution; + } + + async findRecentExecutions( + tenantId: string, + definitionId?: string, + limit: number = 20 + ): Promise { + let sql = ` + SELECT re.*, + rd.name as definition_name, + rd.code as definition_code, + u.full_name as requested_by_name + FROM reports.report_executions re + JOIN reports.report_definitions rd ON re.definition_id = rd.id + JOIN auth.users u ON re.requested_by = u.id + WHERE re.tenant_id = $1 + `; + const params: any[] = [tenantId]; + + if (definitionId) { + sql += ` AND re.definition_id = $2`; + params.push(definitionId); + } + + sql += ` ORDER BY re.created_at DESC LIMIT $${params.length + 1}`; + params.push(limit); + + return query(sql, params); + } + + // ==================== SCHEDULES ==================== + + async findAllSchedules(tenantId: string): Promise { + return query( + `SELECT rs.*, + rd.name as definition_name + FROM reports.report_schedules rs + JOIN reports.report_definitions rd ON rs.definition_id = rd.id + WHERE rs.tenant_id = $1 + ORDER BY rs.name`, + [tenantId] + ); + } + + async createSchedule( + data: { + definition_id: string; + name: string; + cron_expression: string; + default_parameters?: Record; + company_id?: string; + timezone?: string; + delivery_method?: DeliveryMethod; + delivery_config?: Record; + }, + tenantId: string, + userId: string + ): Promise { + // Verificar que la definición existe + await this.findDefinitionById(data.definition_id, tenantId); + + const schedule = await queryOne( + `INSERT INTO reports.report_schedules ( + tenant_id, definition_id, name, cron_expression, + default_parameters, company_id, timezone, + delivery_method, delivery_config, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [ + tenantId, + data.definition_id, + data.name, + data.cron_expression, + JSON.stringify(data.default_parameters || {}), + data.company_id || null, + data.timezone || 'America/Mexico_City', + data.delivery_method || 'none', + JSON.stringify(data.delivery_config || {}), + userId, + ] + ); + + logger.info('Report schedule created', { scheduleId: schedule?.id, name: data.name }); + + return schedule!; + } + + async toggleSchedule(id: string, tenantId: string, isActive: boolean): Promise { + const schedule = await queryOne( + `UPDATE reports.report_schedules + SET is_active = $3, updated_at = NOW() + WHERE id = $1 AND tenant_id = $2 + RETURNING *`, + [id, tenantId, isActive] + ); + + if (!schedule) { + throw new NotFoundError('Programación no encontrada'); + } + + return schedule; + } + + async deleteSchedule(id: string, tenantId: string): Promise { + const result = await query( + `DELETE FROM reports.report_schedules WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + + // Check if any row was deleted + if (!result || result.length === 0) { + // Try to verify it existed + const exists = await queryOne<{ id: string }>( + `SELECT id FROM reports.report_schedules WHERE id = $1`, + [id] + ); + if (!exists) { + throw new NotFoundError('Programación no encontrada'); + } + } + } + + // ==================== QUICK REPORTS ==================== + + async generateTrialBalance( + tenantId: string, + companyId: string | null, + dateFrom: string, + dateTo: string, + includeZero: boolean = false + ): Promise { + return query( + `SELECT * FROM reports.generate_trial_balance($1, $2, $3, $4, $5)`, + [tenantId, companyId, dateFrom, dateTo, includeZero] + ); + } + + async generateGeneralLedger( + tenantId: string, + companyId: string | null, + accountId: string, + dateFrom: string, + dateTo: string + ): Promise { + return query( + `SELECT * FROM reports.generate_general_ledger($1, $2, $3, $4, $5)`, + [tenantId, companyId, accountId, dateFrom, dateTo] + ); + } +} + +export const reportsService = new ReportsService(); diff --git a/src/modules/roles/index.ts b/src/modules/roles/index.ts new file mode 100644 index 0000000..1bf9c73 --- /dev/null +++ b/src/modules/roles/index.ts @@ -0,0 +1,13 @@ +// Roles module exports +export { rolesService } from './roles.service.js'; +export { permissionsService } from './permissions.service.js'; +export { rolesController } from './roles.controller.js'; +export { permissionsController } from './permissions.controller.js'; + +// Routes +export { default as rolesRoutes } from './roles.routes.js'; +export { default as permissionsRoutes } from './permissions.routes.js'; + +// Types +export type { CreateRoleDto, UpdateRoleDto, RoleWithPermissions } from './roles.service.js'; +export type { PermissionFilter, EffectivePermission } from './permissions.service.js'; diff --git a/src/modules/roles/permissions.controller.ts b/src/modules/roles/permissions.controller.ts new file mode 100644 index 0000000..b91c808 --- /dev/null +++ b/src/modules/roles/permissions.controller.ts @@ -0,0 +1,218 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { permissionsService } from './permissions.service.js'; +import { PermissionAction } from '../auth/entities/index.js'; +import { ApiResponse, AuthenticatedRequest, ValidationError, PaginationParams } from '../../shared/types/index.js'; + +// Validation schemas +const checkPermissionsSchema = z.object({ + permissions: z.array(z.object({ + resource: z.string(), + action: z.string(), + })).min(1, 'Se requiere al menos un permiso para verificar'), +}); + +export class PermissionsController { + /** + * GET /permissions - List all permissions with optional filters + */ + async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 50, 200); + const sortBy = req.query.sortBy as string || 'resource'; + const sortOrder = (req.query.sortOrder as 'asc' | 'desc') || 'asc'; + + const params: PaginationParams = { page, limit, sortBy, sortOrder }; + + // Build filter + const filter: { module?: string; resource?: string; action?: PermissionAction } = {}; + if (req.query.module) filter.module = req.query.module as string; + if (req.query.resource) filter.resource = req.query.resource as string; + if (req.query.action) filter.action = req.query.action as PermissionAction; + + const result = await permissionsService.findAll(params, filter); + + const response: ApiResponse = { + success: true, + data: result.permissions, + meta: { + page, + limit, + total: result.total, + totalPages: Math.ceil(result.total / limit), + }, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /permissions/modules - Get list of all modules + */ + async getModules(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const modules = await permissionsService.getModules(); + + const response: ApiResponse = { + success: true, + data: modules, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /permissions/resources - Get list of all resources + */ + async getResources(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const resources = await permissionsService.getResources(); + + const response: ApiResponse = { + success: true, + data: resources, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /permissions/grouped - Get permissions grouped by module + */ + async getGrouped(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const grouped = await permissionsService.getGroupedByModule(); + + const response: ApiResponse = { + success: true, + data: grouped, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /permissions/by-module/:module - Get all permissions for a module + */ + async getByModule(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const module = req.params.module; + const permissions = await permissionsService.getByModule(module); + + const response: ApiResponse = { + success: true, + data: permissions, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /permissions/matrix - Get permission matrix for admin UI + */ + async getMatrix(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const matrix = await permissionsService.getPermissionMatrix(tenantId); + + const response: ApiResponse = { + success: true, + data: matrix, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /permissions/me - Get current user's effective permissions + */ + async getMyPermissions(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const userId = req.user!.userId; + + const permissions = await permissionsService.getEffectivePermissions(tenantId, userId); + + const response: ApiResponse = { + success: true, + data: permissions, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * POST /permissions/check - Check if current user has specific permissions + */ + async checkPermissions(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = checkPermissionsSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const tenantId = req.user!.tenantId; + const userId = req.user!.userId; + + const results = await permissionsService.checkPermissions( + tenantId, + userId, + validation.data.permissions + ); + + const response: ApiResponse = { + success: true, + data: results, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /permissions/user/:userId - Get effective permissions for a specific user (admin) + */ + async getUserPermissions(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const userId = req.params.userId; + + const permissions = await permissionsService.getEffectivePermissions(tenantId, userId); + + const response: ApiResponse = { + success: true, + data: permissions, + }; + + res.json(response); + } catch (error) { + next(error); + } + } +} + +export const permissionsController = new PermissionsController(); diff --git a/src/modules/roles/permissions.routes.ts b/src/modules/roles/permissions.routes.ts new file mode 100644 index 0000000..8e12e3b --- /dev/null +++ b/src/modules/roles/permissions.routes.ts @@ -0,0 +1,55 @@ +import { Router } from 'express'; +import { permissionsController } from './permissions.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// Get current user's permissions (any authenticated user) +router.get('/me', (req, res, next) => + permissionsController.getMyPermissions(req, res, next) +); + +// Check permissions for current user (any authenticated user) +router.post('/check', (req, res, next) => + permissionsController.checkPermissions(req, res, next) +); + +// List all permissions (admin, manager) +router.get('/', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + permissionsController.findAll(req, res, next) +); + +// Get available modules (admin, manager) +router.get('/modules', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + permissionsController.getModules(req, res, next) +); + +// Get available resources (admin, manager) +router.get('/resources', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + permissionsController.getResources(req, res, next) +); + +// Get permissions grouped by module (admin, manager) +router.get('/grouped', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + permissionsController.getGrouped(req, res, next) +); + +// Get permissions by module (admin, manager) +router.get('/by-module/:module', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + permissionsController.getByModule(req, res, next) +); + +// Get permission matrix for admin UI (admin only) +router.get('/matrix', requireRoles('admin', 'super_admin'), (req, res, next) => + permissionsController.getMatrix(req, res, next) +); + +// Get effective permissions for a specific user (admin only) +router.get('/user/:userId', requireRoles('admin', 'super_admin'), (req, res, next) => + permissionsController.getUserPermissions(req, res, next) +); + +export default router; diff --git a/src/modules/roles/permissions.service.ts b/src/modules/roles/permissions.service.ts new file mode 100644 index 0000000..5d5a314 --- /dev/null +++ b/src/modules/roles/permissions.service.ts @@ -0,0 +1,342 @@ +import { Repository, In } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { Permission, PermissionAction, Role, User } from '../auth/entities/index.js'; +import { PaginationParams } from '../../shared/types/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ===== Interfaces ===== + +export interface PermissionFilter { + module?: string; + resource?: string; + action?: PermissionAction; +} + +export interface EffectivePermission { + resource: string; + action: string; + module: string | null; + fromRoles: string[]; +} + +// ===== PermissionsService Class ===== + +class PermissionsService { + private permissionRepository: Repository; + private roleRepository: Repository; + private userRepository: Repository; + + constructor() { + this.permissionRepository = AppDataSource.getRepository(Permission); + this.roleRepository = AppDataSource.getRepository(Role); + this.userRepository = AppDataSource.getRepository(User); + } + + /** + * Get all permissions with optional filtering and pagination + */ + async findAll( + params: PaginationParams, + filter?: PermissionFilter + ): Promise<{ permissions: Permission[]; total: number }> { + try { + const { page, limit, sortBy = 'resource', sortOrder = 'asc' } = params; + const skip = (page - 1) * limit; + + const queryBuilder = this.permissionRepository + .createQueryBuilder('permission') + .orderBy(`permission.${sortBy}`, sortOrder.toUpperCase() as 'ASC' | 'DESC') + .skip(skip) + .take(limit); + + // Apply filters + if (filter?.module) { + queryBuilder.andWhere('permission.module = :module', { module: filter.module }); + } + if (filter?.resource) { + queryBuilder.andWhere('permission.resource LIKE :resource', { + resource: `%${filter.resource}%`, + }); + } + if (filter?.action) { + queryBuilder.andWhere('permission.action = :action', { action: filter.action }); + } + + const [permissions, total] = await queryBuilder.getManyAndCount(); + + logger.debug('Permissions retrieved', { count: permissions.length, total, filter }); + + return { permissions, total }; + } catch (error) { + logger.error('Error retrieving permissions', { + error: (error as Error).message, + }); + throw error; + } + } + + /** + * Get permission by ID + */ + async findById(permissionId: string): Promise { + return await this.permissionRepository.findOne({ + where: { id: permissionId }, + }); + } + + /** + * Get permissions by IDs + */ + async findByIds(permissionIds: string[]): Promise { + return await this.permissionRepository.find({ + where: { id: In(permissionIds) }, + }); + } + + /** + * Get all unique modules + */ + async getModules(): Promise { + const result = await this.permissionRepository + .createQueryBuilder('permission') + .select('DISTINCT permission.module', 'module') + .where('permission.module IS NOT NULL') + .orderBy('permission.module', 'ASC') + .getRawMany(); + + return result.map(r => r.module); + } + + /** + * Get all permissions for a specific module + */ + async getByModule(module: string): Promise { + return await this.permissionRepository.find({ + where: { module }, + order: { resource: 'ASC', action: 'ASC' }, + }); + } + + /** + * Get all unique resources + */ + async getResources(): Promise { + const result = await this.permissionRepository + .createQueryBuilder('permission') + .select('DISTINCT permission.resource', 'resource') + .orderBy('permission.resource', 'ASC') + .getRawMany(); + + return result.map(r => r.resource); + } + + /** + * Get permissions grouped by module + */ + async getGroupedByModule(): Promise> { + const permissions = await this.permissionRepository.find({ + order: { module: 'ASC', resource: 'ASC', action: 'ASC' }, + }); + + const grouped: Record = {}; + + for (const permission of permissions) { + const module = permission.module || 'other'; + if (!grouped[module]) { + grouped[module] = []; + } + grouped[module].push(permission); + } + + return grouped; + } + + /** + * Get effective permissions for a user (combining all role permissions) + */ + async getEffectivePermissions( + tenantId: string, + userId: string + ): Promise { + try { + const user = await this.userRepository.findOne({ + where: { id: userId, tenantId, deletedAt: undefined }, + relations: ['roles', 'roles.permissions'], + }); + + if (!user || !user.roles) { + return []; + } + + // Map to collect permissions with their source roles + const permissionMap = new Map(); + + for (const role of user.roles) { + if (role.deletedAt) continue; + + for (const permission of role.permissions || []) { + const key = `${permission.resource}:${permission.action}`; + + if (permissionMap.has(key)) { + // Add role to existing permission + const existing = permissionMap.get(key)!; + if (!existing.fromRoles.includes(role.name)) { + existing.fromRoles.push(role.name); + } + } else { + // Create new permission entry + permissionMap.set(key, { + resource: permission.resource, + action: permission.action, + module: permission.module, + fromRoles: [role.name], + }); + } + } + } + + const effectivePermissions = Array.from(permissionMap.values()); + + logger.debug('Effective permissions calculated', { + userId, + tenantId, + permissionCount: effectivePermissions.length, + }); + + return effectivePermissions; + } catch (error) { + logger.error('Error calculating effective permissions', { + error: (error as Error).message, + userId, + tenantId, + }); + throw error; + } + } + + /** + * Check if a user has a specific permission + */ + async hasPermission( + tenantId: string, + userId: string, + resource: string, + action: string + ): Promise { + try { + const user = await this.userRepository.findOne({ + where: { id: userId, tenantId, deletedAt: undefined }, + relations: ['roles', 'roles.permissions'], + }); + + if (!user || !user.roles) { + return false; + } + + // Check if user is superuser (has all permissions) + if (user.isSuperuser) { + return true; + } + + // Check through all roles + for (const role of user.roles) { + if (role.deletedAt) continue; + + // Super admin role has all permissions + if (role.code === 'super_admin') { + return true; + } + + for (const permission of role.permissions || []) { + if (permission.resource === resource && permission.action === action) { + return true; + } + } + } + + return false; + } catch (error) { + logger.error('Error checking permission', { + error: (error as Error).message, + userId, + tenantId, + resource, + action, + }); + return false; + } + } + + /** + * Check multiple permissions at once (returns all that user has) + */ + async checkPermissions( + tenantId: string, + userId: string, + permissionChecks: Array<{ resource: string; action: string }> + ): Promise> { + const effectivePermissions = await this.getEffectivePermissions(tenantId, userId); + const permissionSet = new Set( + effectivePermissions.map(p => `${p.resource}:${p.action}`) + ); + + // Check if user is superuser + const user = await this.userRepository.findOne({ + where: { id: userId, tenantId }, + }); + const isSuperuser = user?.isSuperuser || false; + + return permissionChecks.map(check => ({ + resource: check.resource, + action: check.action, + granted: isSuperuser || permissionSet.has(`${check.resource}:${check.action}`), + })); + } + + /** + * Get permission matrix for UI display (roles vs permissions) + */ + async getPermissionMatrix( + tenantId: string + ): Promise<{ + roles: Array<{ id: string; name: string; code: string }>; + permissions: Permission[]; + matrix: Record; + }> { + try { + // Get all roles for tenant + const roles = await this.roleRepository.find({ + where: { tenantId, deletedAt: undefined }, + relations: ['permissions'], + order: { name: 'ASC' }, + }); + + // Get all permissions + const permissions = await this.permissionRepository.find({ + order: { module: 'ASC', resource: 'ASC', action: 'ASC' }, + }); + + // Build matrix: roleId -> [permissionIds] + const matrix: Record = {}; + for (const role of roles) { + matrix[role.id] = (role.permissions || []).map(p => p.id); + } + + return { + roles: roles.map(r => ({ id: r.id, name: r.name, code: r.code })), + permissions, + matrix, + }; + } catch (error) { + logger.error('Error building permission matrix', { + error: (error as Error).message, + tenantId, + }); + throw error; + } + } +} + +// ===== Export Singleton Instance ===== + +export const permissionsService = new PermissionsService(); diff --git a/src/modules/roles/roles.controller.ts b/src/modules/roles/roles.controller.ts new file mode 100644 index 0000000..578ce5c --- /dev/null +++ b/src/modules/roles/roles.controller.ts @@ -0,0 +1,292 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { rolesService } from './roles.service.js'; +import { ApiResponse, AuthenticatedRequest, ValidationError, PaginationParams } from '../../shared/types/index.js'; + +// Validation schemas +const createRoleSchema = z.object({ + name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'), + code: z.string() + .min(2, 'El código debe tener al menos 2 caracteres') + .regex(/^[a-z_]+$/, 'El código debe contener solo letras minúsculas y guiones bajos'), + description: z.string().optional(), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Color debe ser formato hexadecimal (#RRGGBB)').optional(), + permissionIds: z.array(z.string().uuid()).optional(), +}); + +const updateRoleSchema = z.object({ + name: z.string().min(2).optional(), + description: z.string().optional(), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), +}); + +const assignPermissionsSchema = z.object({ + permissionIds: z.array(z.string().uuid('ID de permiso inválido')), +}); + +const addPermissionSchema = z.object({ + permissionId: z.string().uuid('ID de permiso inválido'), +}); + +export class RolesController { + /** + * GET /roles - List all roles for tenant + */ + async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const sortBy = req.query.sortBy as string || 'name'; + const sortOrder = (req.query.sortOrder as 'asc' | 'desc') || 'asc'; + + const params: PaginationParams = { page, limit, sortBy, sortOrder }; + const result = await rolesService.findAll(tenantId, params); + + const response: ApiResponse = { + success: true, + data: result.roles, + meta: { + page, + limit, + total: result.total, + totalPages: Math.ceil(result.total / limit), + }, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /roles/system - Get system roles + */ + async getSystemRoles(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const roles = await rolesService.getSystemRoles(tenantId); + + const response: ApiResponse = { + success: true, + data: roles, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /roles/:id - Get role by ID + */ + async findById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const roleId = req.params.id; + + const role = await rolesService.findById(tenantId, roleId); + + const response: ApiResponse = { + success: true, + data: role, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * POST /roles - Create new role + */ + async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = createRoleSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const tenantId = req.user!.tenantId; + const createdBy = req.user!.userId; + + const role = await rolesService.create(tenantId, validation.data, createdBy); + + const response: ApiResponse = { + success: true, + data: role, + message: 'Rol creado exitosamente', + }; + + res.status(201).json(response); + } catch (error) { + next(error); + } + } + + /** + * PUT /roles/:id - Update role + */ + async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = updateRoleSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const tenantId = req.user!.tenantId; + const roleId = req.params.id; + const updatedBy = req.user!.userId; + + const role = await rolesService.update(tenantId, roleId, validation.data, updatedBy); + + const response: ApiResponse = { + success: true, + data: role, + message: 'Rol actualizado exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * DELETE /roles/:id - Soft delete role + */ + async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const roleId = req.params.id; + const deletedBy = req.user!.userId; + + await rolesService.delete(tenantId, roleId, deletedBy); + + const response: ApiResponse = { + success: true, + message: 'Rol eliminado exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /roles/:id/permissions - Get role permissions + */ + async getPermissions(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const roleId = req.params.id; + + const permissions = await rolesService.getRolePermissions(tenantId, roleId); + + const response: ApiResponse = { + success: true, + data: permissions, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * PUT /roles/:id/permissions - Replace all permissions for a role + */ + async assignPermissions(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = assignPermissionsSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const tenantId = req.user!.tenantId; + const roleId = req.params.id; + const updatedBy = req.user!.userId; + + const role = await rolesService.assignPermissions( + tenantId, + roleId, + validation.data.permissionIds, + updatedBy + ); + + const response: ApiResponse = { + success: true, + data: role, + message: 'Permisos actualizados exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * POST /roles/:id/permissions - Add single permission to role + */ + async addPermission(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = addPermissionSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const tenantId = req.user!.tenantId; + const roleId = req.params.id; + const updatedBy = req.user!.userId; + + const role = await rolesService.addPermission( + tenantId, + roleId, + validation.data.permissionId, + updatedBy + ); + + const response: ApiResponse = { + success: true, + data: role, + message: 'Permiso agregado exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * DELETE /roles/:id/permissions/:permissionId - Remove permission from role + */ + async removePermission(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const roleId = req.params.id; + const permissionId = req.params.permissionId; + const updatedBy = req.user!.userId; + + const role = await rolesService.removePermission(tenantId, roleId, permissionId, updatedBy); + + const response: ApiResponse = { + success: true, + data: role, + message: 'Permiso removido exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } +} + +export const rolesController = new RolesController(); diff --git a/src/modules/roles/roles.routes.ts b/src/modules/roles/roles.routes.ts new file mode 100644 index 0000000..a04920f --- /dev/null +++ b/src/modules/roles/roles.routes.ts @@ -0,0 +1,57 @@ +import { Router } from 'express'; +import { rolesController } from './roles.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// List roles (admin, manager) +router.get('/', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + rolesController.findAll(req, res, next) +); + +// Get system roles (admin) +router.get('/system', requireRoles('admin', 'super_admin'), (req, res, next) => + rolesController.getSystemRoles(req, res, next) +); + +// Get role by ID (admin, manager) +router.get('/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + rolesController.findById(req, res, next) +); + +// Create role (admin only) +router.post('/', requireRoles('admin', 'super_admin'), (req, res, next) => + rolesController.create(req, res, next) +); + +// Update role (admin only) +router.put('/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + rolesController.update(req, res, next) +); + +// Delete role (admin only) +router.delete('/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + rolesController.delete(req, res, next) +); + +// Role permissions management +router.get('/:id/permissions', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + rolesController.getPermissions(req, res, next) +); + +router.put('/:id/permissions', requireRoles('admin', 'super_admin'), (req, res, next) => + rolesController.assignPermissions(req, res, next) +); + +router.post('/:id/permissions', requireRoles('admin', 'super_admin'), (req, res, next) => + rolesController.addPermission(req, res, next) +); + +router.delete('/:id/permissions/:permissionId', requireRoles('admin', 'super_admin'), (req, res, next) => + rolesController.removePermission(req, res, next) +); + +export default router; diff --git a/src/modules/roles/roles.service.ts b/src/modules/roles/roles.service.ts new file mode 100644 index 0000000..5d24572 --- /dev/null +++ b/src/modules/roles/roles.service.ts @@ -0,0 +1,454 @@ +import { Repository, In } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { Role, Permission } from '../auth/entities/index.js'; +import { PaginationParams, NotFoundError, ValidationError, ForbiddenError } from '../../shared/types/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ===== Interfaces ===== + +export interface CreateRoleDto { + name: string; + code: string; + description?: string; + color?: string; + permissionIds?: string[]; +} + +export interface UpdateRoleDto { + name?: string; + description?: string; + color?: string; +} + +export interface RoleWithPermissions extends Role { + permissions: Permission[]; +} + +// ===== RolesService Class ===== + +class RolesService { + private roleRepository: Repository; + private permissionRepository: Repository; + + constructor() { + this.roleRepository = AppDataSource.getRepository(Role); + this.permissionRepository = AppDataSource.getRepository(Permission); + } + + /** + * Get all roles for a tenant with pagination + */ + async findAll( + tenantId: string, + params: PaginationParams + ): Promise<{ roles: Role[]; total: number }> { + try { + const { page, limit, sortBy = 'name', sortOrder = 'asc' } = params; + const skip = (page - 1) * limit; + + const queryBuilder = this.roleRepository + .createQueryBuilder('role') + .leftJoinAndSelect('role.permissions', 'permissions') + .where('role.tenantId = :tenantId', { tenantId }) + .andWhere('role.deletedAt IS NULL') + .orderBy(`role.${sortBy}`, sortOrder.toUpperCase() as 'ASC' | 'DESC') + .skip(skip) + .take(limit); + + const [roles, total] = await queryBuilder.getManyAndCount(); + + logger.debug('Roles retrieved', { tenantId, count: roles.length, total }); + + return { roles, total }; + } catch (error) { + logger.error('Error retrieving roles', { + error: (error as Error).message, + tenantId, + }); + throw error; + } + } + + /** + * Get a specific role by ID + */ + async findById(tenantId: string, roleId: string): Promise { + try { + const role = await this.roleRepository.findOne({ + where: { + id: roleId, + tenantId, + deletedAt: undefined, + }, + relations: ['permissions'], + }); + + if (!role) { + throw new NotFoundError('Rol no encontrado'); + } + + return role as RoleWithPermissions; + } catch (error) { + logger.error('Error finding role', { + error: (error as Error).message, + tenantId, + roleId, + }); + throw error; + } + } + + /** + * Get a role by code + */ + async findByCode(tenantId: string, code: string): Promise { + try { + return await this.roleRepository.findOne({ + where: { + code, + tenantId, + deletedAt: undefined, + }, + relations: ['permissions'], + }); + } catch (error) { + logger.error('Error finding role by code', { + error: (error as Error).message, + tenantId, + code, + }); + throw error; + } + } + + /** + * Create a new role + */ + async create( + tenantId: string, + data: CreateRoleDto, + createdBy: string + ): Promise { + try { + // Validate code uniqueness within tenant + const existing = await this.findByCode(tenantId, data.code); + if (existing) { + throw new ValidationError('Ya existe un rol con este código'); + } + + // Validate code format + if (!/^[a-z_]+$/.test(data.code)) { + throw new ValidationError('El código debe contener solo letras minúsculas y guiones bajos'); + } + + // Create role + const role = this.roleRepository.create({ + tenantId, + name: data.name, + code: data.code, + description: data.description || null, + color: data.color || null, + isSystem: false, + createdBy, + }); + + await this.roleRepository.save(role); + + // Assign initial permissions if provided + if (data.permissionIds && data.permissionIds.length > 0) { + await this.assignPermissions(tenantId, role.id, data.permissionIds, createdBy); + } + + // Reload with permissions + const savedRole = await this.findById(tenantId, role.id); + + logger.info('Role created', { + roleId: role.id, + tenantId, + code: role.code, + createdBy, + }); + + return savedRole; + } catch (error) { + logger.error('Error creating role', { + error: (error as Error).message, + tenantId, + data, + }); + throw error; + } + } + + /** + * Update a role + */ + async update( + tenantId: string, + roleId: string, + data: UpdateRoleDto, + updatedBy: string + ): Promise { + try { + const role = await this.findById(tenantId, roleId); + + // Prevent modification of system roles + if (role.isSystem) { + throw new ForbiddenError('No se pueden modificar roles del sistema'); + } + + // Update allowed fields + if (data.name !== undefined) role.name = data.name; + if (data.description !== undefined) role.description = data.description; + if (data.color !== undefined) role.color = data.color; + role.updatedBy = updatedBy; + role.updatedAt = new Date(); + + await this.roleRepository.save(role); + + logger.info('Role updated', { + roleId, + tenantId, + updatedBy, + }); + + return await this.findById(tenantId, roleId); + } catch (error) { + logger.error('Error updating role', { + error: (error as Error).message, + tenantId, + roleId, + }); + throw error; + } + } + + /** + * Soft delete a role + */ + async delete(tenantId: string, roleId: string, deletedBy: string): Promise { + try { + const role = await this.findById(tenantId, roleId); + + // Prevent deletion of system roles + if (role.isSystem) { + throw new ForbiddenError('No se pueden eliminar roles del sistema'); + } + + // Check if role has users assigned + const usersCount = await this.roleRepository + .createQueryBuilder('role') + .leftJoin('role.users', 'user') + .where('role.id = :roleId', { roleId }) + .andWhere('user.deletedAt IS NULL') + .getCount(); + + if (usersCount > 0) { + throw new ValidationError( + `No se puede eliminar el rol porque tiene ${usersCount} usuario(s) asignado(s)` + ); + } + + // Soft delete + role.deletedAt = new Date(); + role.deletedBy = deletedBy; + + await this.roleRepository.save(role); + + logger.info('Role deleted', { + roleId, + tenantId, + deletedBy, + }); + } catch (error) { + logger.error('Error deleting role', { + error: (error as Error).message, + tenantId, + roleId, + }); + throw error; + } + } + + /** + * Assign permissions to a role + */ + async assignPermissions( + tenantId: string, + roleId: string, + permissionIds: string[], + updatedBy: string + ): Promise { + try { + const role = await this.findById(tenantId, roleId); + + // Prevent modification of system roles + if (role.isSystem) { + throw new ForbiddenError('No se pueden modificar permisos de roles del sistema'); + } + + // Validate all permissions exist + const permissions = await this.permissionRepository.find({ + where: { id: In(permissionIds) }, + }); + + if (permissions.length !== permissionIds.length) { + throw new ValidationError('Uno o más permisos no existen'); + } + + // Replace permissions + role.permissions = permissions; + role.updatedBy = updatedBy; + role.updatedAt = new Date(); + + await this.roleRepository.save(role); + + logger.info('Role permissions updated', { + roleId, + tenantId, + permissionCount: permissions.length, + updatedBy, + }); + + return await this.findById(tenantId, roleId); + } catch (error) { + logger.error('Error assigning permissions', { + error: (error as Error).message, + tenantId, + roleId, + }); + throw error; + } + } + + /** + * Add a single permission to a role + */ + async addPermission( + tenantId: string, + roleId: string, + permissionId: string, + updatedBy: string + ): Promise { + try { + const role = await this.findById(tenantId, roleId); + + if (role.isSystem) { + throw new ForbiddenError('No se pueden modificar permisos de roles del sistema'); + } + + // Check if permission exists + const permission = await this.permissionRepository.findOne({ + where: { id: permissionId }, + }); + + if (!permission) { + throw new NotFoundError('Permiso no encontrado'); + } + + // Check if already assigned + const hasPermission = role.permissions.some(p => p.id === permissionId); + if (hasPermission) { + throw new ValidationError('El permiso ya está asignado a este rol'); + } + + // Add permission + role.permissions.push(permission); + role.updatedBy = updatedBy; + role.updatedAt = new Date(); + + await this.roleRepository.save(role); + + logger.info('Permission added to role', { + roleId, + permissionId, + tenantId, + updatedBy, + }); + + return await this.findById(tenantId, roleId); + } catch (error) { + logger.error('Error adding permission', { + error: (error as Error).message, + tenantId, + roleId, + permissionId, + }); + throw error; + } + } + + /** + * Remove a permission from a role + */ + async removePermission( + tenantId: string, + roleId: string, + permissionId: string, + updatedBy: string + ): Promise { + try { + const role = await this.findById(tenantId, roleId); + + if (role.isSystem) { + throw new ForbiddenError('No se pueden modificar permisos de roles del sistema'); + } + + // Filter out the permission + const initialLength = role.permissions.length; + role.permissions = role.permissions.filter(p => p.id !== permissionId); + + if (role.permissions.length === initialLength) { + throw new NotFoundError('El permiso no está asignado a este rol'); + } + + role.updatedBy = updatedBy; + role.updatedAt = new Date(); + + await this.roleRepository.save(role); + + logger.info('Permission removed from role', { + roleId, + permissionId, + tenantId, + updatedBy, + }); + + return await this.findById(tenantId, roleId); + } catch (error) { + logger.error('Error removing permission', { + error: (error as Error).message, + tenantId, + roleId, + permissionId, + }); + throw error; + } + } + + /** + * Get all permissions for a role + */ + async getRolePermissions(tenantId: string, roleId: string): Promise { + const role = await this.findById(tenantId, roleId); + return role.permissions; + } + + /** + * Get system roles (super_admin, admin, etc.) + */ + async getSystemRoles(tenantId: string): Promise { + return await this.roleRepository.find({ + where: { + tenantId, + isSystem: true, + deletedAt: undefined, + }, + relations: ['permissions'], + }); + } +} + +// ===== Export Singleton Instance ===== + +export const rolesService = new RolesService(); diff --git a/src/modules/sales/customer-groups.service.ts b/src/modules/sales/customer-groups.service.ts new file mode 100644 index 0000000..5a16503 --- /dev/null +++ b/src/modules/sales/customer-groups.service.ts @@ -0,0 +1,209 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ConflictError } from '../../shared/errors/index.js'; + +export interface CustomerGroupMember { + id: string; + customer_group_id: string; + partner_id: string; + partner_name?: string; + joined_at: Date; +} + +export interface CustomerGroup { + id: string; + tenant_id: string; + name: string; + description?: string; + discount_percentage: number; + members?: CustomerGroupMember[]; + member_count?: number; + created_at: Date; +} + +export interface CreateCustomerGroupDto { + name: string; + description?: string; + discount_percentage?: number; +} + +export interface UpdateCustomerGroupDto { + name?: string; + description?: string | null; + discount_percentage?: number; +} + +export interface CustomerGroupFilters { + search?: string; + page?: number; + limit?: number; +} + +class CustomerGroupsService { + async findAll(tenantId: string, filters: CustomerGroupFilters = {}): Promise<{ data: CustomerGroup[]; total: number }> { + const { search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE cg.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (search) { + whereClause += ` AND (cg.name ILIKE $${paramIndex} OR cg.description ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM sales.customer_groups cg ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT cg.*, + (SELECT COUNT(*) FROM sales.customer_group_members cgm WHERE cgm.customer_group_id = cg.id) as member_count + FROM sales.customer_groups cg + ${whereClause} + ORDER BY cg.name + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const group = await queryOne( + `SELECT cg.*, + (SELECT COUNT(*) FROM sales.customer_group_members cgm WHERE cgm.customer_group_id = cg.id) as member_count + FROM sales.customer_groups cg + WHERE cg.id = $1 AND cg.tenant_id = $2`, + [id, tenantId] + ); + + if (!group) { + throw new NotFoundError('Grupo de clientes no encontrado'); + } + + // Get members + const members = await query( + `SELECT cgm.*, + p.name as partner_name + FROM sales.customer_group_members cgm + LEFT JOIN core.partners p ON cgm.partner_id = p.id + WHERE cgm.customer_group_id = $1 + ORDER BY p.name`, + [id] + ); + + group.members = members; + + return group; + } + + async create(dto: CreateCustomerGroupDto, tenantId: string, userId: string): Promise { + // Check unique name + const existing = await queryOne( + `SELECT id FROM sales.customer_groups WHERE tenant_id = $1 AND name = $2`, + [tenantId, dto.name] + ); + + if (existing) { + throw new ConflictError('Ya existe un grupo de clientes con ese nombre'); + } + + const group = await queryOne( + `INSERT INTO sales.customer_groups (tenant_id, name, description, discount_percentage, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [tenantId, dto.name, dto.description, dto.discount_percentage || 0, userId] + ); + + return group!; + } + + async update(id: string, dto: UpdateCustomerGroupDto, tenantId: string): Promise { + await this.findById(id, tenantId); + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.name !== undefined) { + // Check unique name + const existing = await queryOne( + `SELECT id FROM sales.customer_groups WHERE tenant_id = $1 AND name = $2 AND id != $3`, + [tenantId, dto.name, id] + ); + if (existing) { + throw new ConflictError('Ya existe un grupo de clientes con ese nombre'); + } + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.description !== undefined) { + updateFields.push(`description = $${paramIndex++}`); + values.push(dto.description); + } + if (dto.discount_percentage !== undefined) { + updateFields.push(`discount_percentage = $${paramIndex++}`); + values.push(dto.discount_percentage); + } + + values.push(id, tenantId); + + await query( + `UPDATE sales.customer_groups SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + const group = await this.findById(id, tenantId); + + if (group.member_count && group.member_count > 0) { + throw new ConflictError('No se puede eliminar un grupo con miembros'); + } + + await query(`DELETE FROM sales.customer_groups WHERE id = $1 AND tenant_id = $2`, [id, tenantId]); + } + + async addMember(groupId: string, partnerId: string, tenantId: string): Promise { + await this.findById(groupId, tenantId); + + // Check if already member + const existing = await queryOne( + `SELECT id FROM sales.customer_group_members WHERE customer_group_id = $1 AND partner_id = $2`, + [groupId, partnerId] + ); + if (existing) { + throw new ConflictError('El cliente ya es miembro de este grupo'); + } + + const member = await queryOne( + `INSERT INTO sales.customer_group_members (customer_group_id, partner_id) + VALUES ($1, $2) + RETURNING *`, + [groupId, partnerId] + ); + + return member!; + } + + async removeMember(groupId: string, memberId: string, tenantId: string): Promise { + await this.findById(groupId, tenantId); + + await query( + `DELETE FROM sales.customer_group_members WHERE id = $1 AND customer_group_id = $2`, + [memberId, groupId] + ); + } +} + +export const customerGroupsService = new CustomerGroupsService(); diff --git a/src/modules/sales/index.ts b/src/modules/sales/index.ts new file mode 100644 index 0000000..31f7ef6 --- /dev/null +++ b/src/modules/sales/index.ts @@ -0,0 +1,7 @@ +export * from './pricelists.service.js'; +export * from './sales-teams.service.js'; +export * from './customer-groups.service.js'; +export * from './quotations.service.js'; +export * from './orders.service.js'; +export * from './sales.controller.js'; +export { default as salesRoutes } from './sales.routes.js'; diff --git a/src/modules/sales/orders.service.ts b/src/modules/sales/orders.service.ts new file mode 100644 index 0000000..cca04fc --- /dev/null +++ b/src/modules/sales/orders.service.ts @@ -0,0 +1,707 @@ +import { query, queryOne, getClient } from '../../config/database.js'; +import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js'; +import { taxesService } from '../financial/taxes.service.js'; +import { sequencesService, SEQUENCE_CODES } from '../core/sequences.service.js'; + +export interface SalesOrderLine { + id: string; + order_id: string; + product_id: string; + product_name?: string; + description: string; + quantity: number; + qty_delivered: number; + qty_invoiced: number; + uom_id: string; + uom_name?: string; + price_unit: number; + discount: number; + tax_ids: string[]; + amount_untaxed: number; + amount_tax: number; + amount_total: number; + analytic_account_id?: string; +} + +export interface SalesOrder { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + name: string; + client_order_ref?: string; + partner_id: string; + partner_name?: string; + order_date: Date; + validity_date?: Date; + commitment_date?: Date; + currency_id: string; + currency_code?: string; + pricelist_id?: string; + pricelist_name?: string; + payment_term_id?: string; + user_id?: string; + user_name?: string; + sales_team_id?: string; + sales_team_name?: string; + amount_untaxed: number; + amount_tax: number; + amount_total: number; + status: 'draft' | 'sent' | 'sale' | 'done' | 'cancelled'; + invoice_status: 'pending' | 'partial' | 'invoiced'; + delivery_status: 'pending' | 'partial' | 'delivered'; + invoice_policy: 'order' | 'delivery'; + picking_id?: string; + notes?: string; + terms_conditions?: string; + lines?: SalesOrderLine[]; + created_at: Date; + confirmed_at?: Date; +} + +export interface CreateSalesOrderDto { + company_id: string; + partner_id: string; + client_order_ref?: string; + order_date?: string; + validity_date?: string; + commitment_date?: string; + currency_id: string; + pricelist_id?: string; + payment_term_id?: string; + sales_team_id?: string; + invoice_policy?: 'order' | 'delivery'; + notes?: string; + terms_conditions?: string; +} + +export interface UpdateSalesOrderDto { + partner_id?: string; + client_order_ref?: string | null; + order_date?: string; + validity_date?: string | null; + commitment_date?: string | null; + currency_id?: string; + pricelist_id?: string | null; + payment_term_id?: string | null; + sales_team_id?: string | null; + invoice_policy?: 'order' | 'delivery'; + notes?: string | null; + terms_conditions?: string | null; +} + +export interface CreateSalesOrderLineDto { + product_id: string; + description: string; + quantity: number; + uom_id: string; + price_unit: number; + discount?: number; + tax_ids?: string[]; + analytic_account_id?: string; +} + +export interface UpdateSalesOrderLineDto { + description?: string; + quantity?: number; + uom_id?: string; + price_unit?: number; + discount?: number; + tax_ids?: string[]; + analytic_account_id?: string | null; +} + +export interface SalesOrderFilters { + company_id?: string; + partner_id?: string; + status?: string; + invoice_status?: string; + delivery_status?: string; + date_from?: string; + date_to?: string; + search?: string; + page?: number; + limit?: number; +} + +class OrdersService { + async findAll(tenantId: string, filters: SalesOrderFilters = {}): Promise<{ data: SalesOrder[]; total: number }> { + const { company_id, partner_id, status, invoice_status, delivery_status, date_from, date_to, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE so.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND so.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (partner_id) { + whereClause += ` AND so.partner_id = $${paramIndex++}`; + params.push(partner_id); + } + + if (status) { + whereClause += ` AND so.status = $${paramIndex++}`; + params.push(status); + } + + if (invoice_status) { + whereClause += ` AND so.invoice_status = $${paramIndex++}`; + params.push(invoice_status); + } + + if (delivery_status) { + whereClause += ` AND so.delivery_status = $${paramIndex++}`; + params.push(delivery_status); + } + + if (date_from) { + whereClause += ` AND so.order_date >= $${paramIndex++}`; + params.push(date_from); + } + + if (date_to) { + whereClause += ` AND so.order_date <= $${paramIndex++}`; + params.push(date_to); + } + + if (search) { + whereClause += ` AND (so.name ILIKE $${paramIndex} OR so.client_order_ref ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count + FROM sales.sales_orders so + LEFT JOIN core.partners p ON so.partner_id = p.id + ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT so.*, + c.name as company_name, + p.name as partner_name, + cu.code as currency_code, + pl.name as pricelist_name, + u.name as user_name, + st.name as sales_team_name + FROM sales.sales_orders so + LEFT JOIN auth.companies c ON so.company_id = c.id + LEFT JOIN core.partners p ON so.partner_id = p.id + LEFT JOIN core.currencies cu ON so.currency_id = cu.id + LEFT JOIN sales.pricelists pl ON so.pricelist_id = pl.id + LEFT JOIN auth.users u ON so.user_id = u.id + LEFT JOIN sales.sales_teams st ON so.sales_team_id = st.id + ${whereClause} + ORDER BY so.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const order = await queryOne( + `SELECT so.*, + c.name as company_name, + p.name as partner_name, + cu.code as currency_code, + pl.name as pricelist_name, + u.name as user_name, + st.name as sales_team_name + FROM sales.sales_orders so + LEFT JOIN auth.companies c ON so.company_id = c.id + LEFT JOIN core.partners p ON so.partner_id = p.id + LEFT JOIN core.currencies cu ON so.currency_id = cu.id + LEFT JOIN sales.pricelists pl ON so.pricelist_id = pl.id + LEFT JOIN auth.users u ON so.user_id = u.id + LEFT JOIN sales.sales_teams st ON so.sales_team_id = st.id + WHERE so.id = $1 AND so.tenant_id = $2`, + [id, tenantId] + ); + + if (!order) { + throw new NotFoundError('Orden de venta no encontrada'); + } + + // Get lines + const lines = await query( + `SELECT sol.*, + pr.name as product_name, + um.name as uom_name + FROM sales.sales_order_lines sol + LEFT JOIN inventory.products pr ON sol.product_id = pr.id + LEFT JOIN core.uom um ON sol.uom_id = um.id + WHERE sol.order_id = $1 + ORDER BY sol.created_at`, + [id] + ); + + order.lines = lines; + + return order; + } + + async create(dto: CreateSalesOrderDto, tenantId: string, userId: string): Promise { + // Generate sequence number using atomic database function + const orderNumber = await sequencesService.getNextNumber(SEQUENCE_CODES.SALES_ORDER, tenantId); + + const orderDate = dto.order_date || new Date().toISOString().split('T')[0]; + + const order = await queryOne( + `INSERT INTO sales.sales_orders ( + tenant_id, company_id, name, client_order_ref, partner_id, order_date, + validity_date, commitment_date, currency_id, pricelist_id, payment_term_id, + user_id, sales_team_id, invoice_policy, notes, terms_conditions, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + RETURNING *`, + [ + tenantId, dto.company_id, orderNumber, dto.client_order_ref, dto.partner_id, + orderDate, dto.validity_date, dto.commitment_date, dto.currency_id, + dto.pricelist_id, dto.payment_term_id, userId, dto.sales_team_id, + dto.invoice_policy || 'order', dto.notes, dto.terms_conditions, userId + ] + ); + + return order!; + } + + async update(id: string, dto: UpdateSalesOrderDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'draft') { + throw new ValidationError('Solo se pueden editar órdenes en estado borrador'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.partner_id !== undefined) { + updateFields.push(`partner_id = $${paramIndex++}`); + values.push(dto.partner_id); + } + if (dto.client_order_ref !== undefined) { + updateFields.push(`client_order_ref = $${paramIndex++}`); + values.push(dto.client_order_ref); + } + if (dto.order_date !== undefined) { + updateFields.push(`order_date = $${paramIndex++}`); + values.push(dto.order_date); + } + if (dto.validity_date !== undefined) { + updateFields.push(`validity_date = $${paramIndex++}`); + values.push(dto.validity_date); + } + if (dto.commitment_date !== undefined) { + updateFields.push(`commitment_date = $${paramIndex++}`); + values.push(dto.commitment_date); + } + if (dto.currency_id !== undefined) { + updateFields.push(`currency_id = $${paramIndex++}`); + values.push(dto.currency_id); + } + if (dto.pricelist_id !== undefined) { + updateFields.push(`pricelist_id = $${paramIndex++}`); + values.push(dto.pricelist_id); + } + if (dto.payment_term_id !== undefined) { + updateFields.push(`payment_term_id = $${paramIndex++}`); + values.push(dto.payment_term_id); + } + if (dto.sales_team_id !== undefined) { + updateFields.push(`sales_team_id = $${paramIndex++}`); + values.push(dto.sales_team_id); + } + if (dto.invoice_policy !== undefined) { + updateFields.push(`invoice_policy = $${paramIndex++}`); + values.push(dto.invoice_policy); + } + if (dto.notes !== undefined) { + updateFields.push(`notes = $${paramIndex++}`); + values.push(dto.notes); + } + if (dto.terms_conditions !== undefined) { + updateFields.push(`terms_conditions = $${paramIndex++}`); + values.push(dto.terms_conditions); + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + await query( + `UPDATE sales.sales_orders SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'draft') { + throw new ValidationError('Solo se pueden eliminar órdenes en estado borrador'); + } + + await query( + `DELETE FROM sales.sales_orders WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + } + + async addLine(orderId: string, dto: CreateSalesOrderLineDto, tenantId: string, userId: string): Promise { + const order = await this.findById(orderId, tenantId); + + if (order.status !== 'draft') { + throw new ValidationError('Solo se pueden agregar líneas a órdenes en estado borrador'); + } + + // Calculate amounts with taxes using taxesService + const taxResult = await taxesService.calculateTaxes( + { + quantity: dto.quantity, + priceUnit: dto.price_unit, + discount: dto.discount || 0, + taxIds: dto.tax_ids || [], + }, + tenantId, + 'sales' + ); + const amountUntaxed = taxResult.amountUntaxed; + const amountTax = taxResult.amountTax; + const amountTotal = taxResult.amountTotal; + + const line = await queryOne( + `INSERT INTO sales.sales_order_lines ( + order_id, tenant_id, product_id, description, quantity, uom_id, + price_unit, discount, tax_ids, amount_untaxed, amount_tax, amount_total, analytic_account_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING *`, + [ + orderId, tenantId, dto.product_id, dto.description, dto.quantity, dto.uom_id, + dto.price_unit, dto.discount || 0, dto.tax_ids || [], amountUntaxed, amountTax, amountTotal, dto.analytic_account_id + ] + ); + + // Update order totals + await this.updateTotals(orderId); + + return line!; + } + + async updateLine(orderId: string, lineId: string, dto: UpdateSalesOrderLineDto, tenantId: string): Promise { + const order = await this.findById(orderId, tenantId); + + if (order.status !== 'draft') { + throw new ValidationError('Solo se pueden editar líneas de órdenes en estado borrador'); + } + + const existingLine = order.lines?.find(l => l.id === lineId); + if (!existingLine) { + throw new NotFoundError('Línea de orden no encontrada'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + const quantity = dto.quantity ?? existingLine.quantity; + const priceUnit = dto.price_unit ?? existingLine.price_unit; + const discount = dto.discount ?? existingLine.discount; + + if (dto.description !== undefined) { + updateFields.push(`description = $${paramIndex++}`); + values.push(dto.description); + } + if (dto.quantity !== undefined) { + updateFields.push(`quantity = $${paramIndex++}`); + values.push(dto.quantity); + } + if (dto.uom_id !== undefined) { + updateFields.push(`uom_id = $${paramIndex++}`); + values.push(dto.uom_id); + } + if (dto.price_unit !== undefined) { + updateFields.push(`price_unit = $${paramIndex++}`); + values.push(dto.price_unit); + } + if (dto.discount !== undefined) { + updateFields.push(`discount = $${paramIndex++}`); + values.push(dto.discount); + } + if (dto.tax_ids !== undefined) { + updateFields.push(`tax_ids = $${paramIndex++}`); + values.push(dto.tax_ids); + } + if (dto.analytic_account_id !== undefined) { + updateFields.push(`analytic_account_id = $${paramIndex++}`); + values.push(dto.analytic_account_id); + } + + // Recalculate amounts + const subtotal = quantity * priceUnit; + const discountAmount = subtotal * discount / 100; + const amountUntaxed = subtotal - discountAmount; + const amountTax = 0; // TODO: Calculate taxes + const amountTotal = amountUntaxed + amountTax; + + updateFields.push(`amount_untaxed = $${paramIndex++}`); + values.push(amountUntaxed); + updateFields.push(`amount_tax = $${paramIndex++}`); + values.push(amountTax); + updateFields.push(`amount_total = $${paramIndex++}`); + values.push(amountTotal); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(lineId, orderId); + + await query( + `UPDATE sales.sales_order_lines SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND order_id = $${paramIndex}`, + values + ); + + // Update order totals + await this.updateTotals(orderId); + + const updated = await queryOne( + `SELECT * FROM sales.sales_order_lines WHERE id = $1`, + [lineId] + ); + + return updated!; + } + + async removeLine(orderId: string, lineId: string, tenantId: string): Promise { + const order = await this.findById(orderId, tenantId); + + if (order.status !== 'draft') { + throw new ValidationError('Solo se pueden eliminar líneas de órdenes en estado borrador'); + } + + await query( + `DELETE FROM sales.sales_order_lines WHERE id = $1 AND order_id = $2`, + [lineId, orderId] + ); + + // Update order totals + await this.updateTotals(orderId); + } + + async confirm(id: string, tenantId: string, userId: string): Promise { + const order = await this.findById(id, tenantId); + + if (order.status !== 'draft') { + throw new ValidationError('Solo se pueden confirmar órdenes en estado borrador'); + } + + if (!order.lines || order.lines.length === 0) { + throw new ValidationError('La orden debe tener al menos una línea'); + } + + const client = await getClient(); + try { + await client.query('BEGIN'); + + // Update order status to 'sent' (Odoo-compatible: quotation sent to customer) + await client.query( + `UPDATE sales.sales_orders SET + status = 'sent', + confirmed_at = CURRENT_TIMESTAMP, + confirmed_by = $1, + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2`, + [userId, id] + ); + + // Create delivery picking (optional - depends on business logic) + // This would create an inventory.pickings record for delivery + + await client.query('COMMIT'); + + return this.findById(id, tenantId); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async cancel(id: string, tenantId: string, userId: string): Promise { + const order = await this.findById(id, tenantId); + + if (order.status === 'done') { + throw new ValidationError('No se pueden cancelar órdenes completadas'); + } + + if (order.status === 'cancelled') { + throw new ValidationError('La orden ya está cancelada'); + } + + // Check if there are any deliveries or invoices + if (order.delivery_status !== 'pending') { + throw new ValidationError('No se puede cancelar: ya hay entregas asociadas'); + } + + if (order.invoice_status !== 'pending') { + throw new ValidationError('No se puede cancelar: ya hay facturas asociadas'); + } + + await query( + `UPDATE sales.sales_orders SET + status = 'cancelled', + cancelled_at = CURRENT_TIMESTAMP, + cancelled_by = $1, + updated_by = $1, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + async createInvoice(id: string, tenantId: string, userId: string): Promise<{ orderId: string; invoiceId: string }> { + const order = await this.findById(id, tenantId); + + if (order.status !== 'sent' && order.status !== 'sale' && order.status !== 'done') { + throw new ValidationError('Solo se pueden facturar órdenes confirmadas (sent/sale)'); + } + + if (order.invoice_status === 'invoiced') { + throw new ValidationError('La orden ya está completamente facturada'); + } + + // Check if there are quantities to invoice + const linesToInvoice = order.lines?.filter(l => { + if (order.invoice_policy === 'order') { + return l.quantity > l.qty_invoiced; + } else { + return l.qty_delivered > l.qty_invoiced; + } + }); + + if (!linesToInvoice || linesToInvoice.length === 0) { + throw new ValidationError('No hay líneas para facturar'); + } + + const client = await getClient(); + try { + await client.query('BEGIN'); + + // Generate invoice number + const seqResult = await client.query( + `SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 5) AS INTEGER)), 0) + 1 as next_num + FROM financial.invoices WHERE tenant_id = $1 AND name LIKE 'INV-%'`, + [tenantId] + ); + const invoiceNumber = `INV-${String(seqResult.rows[0]?.next_num || 1).padStart(6, '0')}`; + + // Create invoice + const invoiceResult = await client.query( + `INSERT INTO financial.invoices ( + tenant_id, company_id, name, partner_id, invoice_date, due_date, + currency_id, invoice_type, amount_untaxed, amount_tax, amount_total, + source_document, created_by + ) + VALUES ($1, $2, $3, $4, CURRENT_DATE, CURRENT_DATE + INTERVAL '30 days', + $5, 'customer', 0, 0, 0, $6, $7) + RETURNING id`, + [tenantId, order.company_id, invoiceNumber, order.partner_id, order.currency_id, order.name, userId] + ); + const invoiceId = invoiceResult.rows[0].id; + + // Create invoice lines and update qty_invoiced + for (const line of linesToInvoice) { + const qtyToInvoice = order.invoice_policy === 'order' + ? line.quantity - line.qty_invoiced + : line.qty_delivered - line.qty_invoiced; + + const lineAmount = qtyToInvoice * line.price_unit * (1 - line.discount / 100); + + await client.query( + `INSERT INTO financial.invoice_lines ( + invoice_id, tenant_id, product_id, description, quantity, uom_id, + price_unit, discount, amount_untaxed, amount_tax, amount_total + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, $9)`, + [invoiceId, tenantId, line.product_id, line.description, qtyToInvoice, line.uom_id, line.price_unit, line.discount, lineAmount] + ); + + await client.query( + `UPDATE sales.sales_order_lines SET qty_invoiced = qty_invoiced + $1 WHERE id = $2`, + [qtyToInvoice, line.id] + ); + } + + // Update invoice totals + await client.query( + `UPDATE financial.invoices SET + amount_untaxed = (SELECT COALESCE(SUM(amount_untaxed), 0) FROM financial.invoice_lines WHERE invoice_id = $1), + amount_total = (SELECT COALESCE(SUM(amount_total), 0) FROM financial.invoice_lines WHERE invoice_id = $1) + WHERE id = $1`, + [invoiceId] + ); + + // Update order invoice_status + await client.query( + `UPDATE sales.sales_orders SET + invoice_status = CASE + WHEN (SELECT SUM(qty_invoiced) FROM sales.sales_order_lines WHERE order_id = $1) >= + (SELECT SUM(quantity) FROM sales.sales_order_lines WHERE order_id = $1) + THEN 'invoiced'::sales.invoice_status + ELSE 'partial'::sales.invoice_status + END, + updated_by = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1`, + [id, userId] + ); + + await client.query('COMMIT'); + + return { orderId: id, invoiceId }; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + private async updateTotals(orderId: string): Promise { + await query( + `UPDATE sales.sales_orders SET + amount_untaxed = COALESCE((SELECT SUM(amount_untaxed) FROM sales.sales_order_lines WHERE order_id = $1), 0), + amount_tax = COALESCE((SELECT SUM(amount_tax) FROM sales.sales_order_lines WHERE order_id = $1), 0), + amount_total = COALESCE((SELECT SUM(amount_total) FROM sales.sales_order_lines WHERE order_id = $1), 0) + WHERE id = $1`, + [orderId] + ); + } +} + +export const ordersService = new OrdersService(); diff --git a/src/modules/sales/pricelists.service.ts b/src/modules/sales/pricelists.service.ts new file mode 100644 index 0000000..edbe75f --- /dev/null +++ b/src/modules/sales/pricelists.service.ts @@ -0,0 +1,249 @@ +import { query, queryOne, getClient } from '../../config/database.js'; +import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js'; + +export interface PricelistItem { + id: string; + pricelist_id: string; + product_id?: string; + product_name?: string; + product_category_id?: string; + category_name?: string; + price: number; + min_quantity: number; + valid_from?: Date; + valid_to?: Date; + active: boolean; +} + +export interface Pricelist { + id: string; + tenant_id: string; + company_id?: string; + company_name?: string; + name: string; + currency_id: string; + currency_code?: string; + active: boolean; + items?: PricelistItem[]; + created_at: Date; +} + +export interface CreatePricelistDto { + company_id?: string; + name: string; + currency_id: string; +} + +export interface UpdatePricelistDto { + name?: string; + currency_id?: string; + active?: boolean; +} + +export interface CreatePricelistItemDto { + product_id?: string; + product_category_id?: string; + price: number; + min_quantity?: number; + valid_from?: string; + valid_to?: string; +} + +export interface PricelistFilters { + company_id?: string; + active?: boolean; + page?: number; + limit?: number; +} + +class PricelistsService { + async findAll(tenantId: string, filters: PricelistFilters = {}): Promise<{ data: Pricelist[]; total: number }> { + const { company_id, active, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE p.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND p.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (active !== undefined) { + whereClause += ` AND p.active = $${paramIndex++}`; + params.push(active); + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM sales.pricelists p ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT p.*, + c.name as company_name, + cu.code as currency_code + FROM sales.pricelists p + LEFT JOIN auth.companies c ON p.company_id = c.id + LEFT JOIN core.currencies cu ON p.currency_id = cu.id + ${whereClause} + ORDER BY p.name + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const pricelist = await queryOne( + `SELECT p.*, + c.name as company_name, + cu.code as currency_code + FROM sales.pricelists p + LEFT JOIN auth.companies c ON p.company_id = c.id + LEFT JOIN core.currencies cu ON p.currency_id = cu.id + WHERE p.id = $1 AND p.tenant_id = $2`, + [id, tenantId] + ); + + if (!pricelist) { + throw new NotFoundError('Lista de precios no encontrada'); + } + + // Get items + const items = await query( + `SELECT pi.*, + pr.name as product_name, + pc.name as category_name + FROM sales.pricelist_items pi + LEFT JOIN inventory.products pr ON pi.product_id = pr.id + LEFT JOIN core.product_categories pc ON pi.product_category_id = pc.id + WHERE pi.pricelist_id = $1 + ORDER BY pi.min_quantity, pr.name`, + [id] + ); + + pricelist.items = items; + + return pricelist; + } + + async create(dto: CreatePricelistDto, tenantId: string, userId: string): Promise { + // Check unique name + const existing = await queryOne( + `SELECT id FROM sales.pricelists WHERE tenant_id = $1 AND name = $2`, + [tenantId, dto.name] + ); + + if (existing) { + throw new ConflictError('Ya existe una lista de precios con ese nombre'); + } + + const pricelist = await queryOne( + `INSERT INTO sales.pricelists (tenant_id, company_id, name, currency_id, created_by) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [tenantId, dto.company_id, dto.name, dto.currency_id, userId] + ); + + return pricelist!; + } + + async update(id: string, dto: UpdatePricelistDto, tenantId: string, userId: string): Promise { + await this.findById(id, tenantId); + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.name !== undefined) { + // Check unique name + const existing = await queryOne( + `SELECT id FROM sales.pricelists WHERE tenant_id = $1 AND name = $2 AND id != $3`, + [tenantId, dto.name, id] + ); + if (existing) { + throw new ConflictError('Ya existe una lista de precios con ese nombre'); + } + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.currency_id !== undefined) { + updateFields.push(`currency_id = $${paramIndex++}`); + values.push(dto.currency_id); + } + if (dto.active !== undefined) { + updateFields.push(`active = $${paramIndex++}`); + values.push(dto.active); + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + await query( + `UPDATE sales.pricelists SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async addItem(pricelistId: string, dto: CreatePricelistItemDto, tenantId: string, userId: string): Promise { + await this.findById(pricelistId, tenantId); + + if (!dto.product_id && !dto.product_category_id) { + throw new ValidationError('Debe especificar un producto o una categoría'); + } + + if (dto.product_id && dto.product_category_id) { + throw new ValidationError('Debe especificar solo un producto o solo una categoría, no ambos'); + } + + const item = await queryOne( + `INSERT INTO sales.pricelist_items (pricelist_id, product_id, product_category_id, price, min_quantity, valid_from, valid_to, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [pricelistId, dto.product_id, dto.product_category_id, dto.price, dto.min_quantity || 1, dto.valid_from, dto.valid_to, userId] + ); + + return item!; + } + + async removeItem(pricelistId: string, itemId: string, tenantId: string): Promise { + await this.findById(pricelistId, tenantId); + + const result = await query( + `DELETE FROM sales.pricelist_items WHERE id = $1 AND pricelist_id = $2`, + [itemId, pricelistId] + ); + } + + async getProductPrice(productId: string, pricelistId: string, quantity: number = 1): Promise { + const item = await queryOne<{ price: number }>( + `SELECT price FROM sales.pricelist_items + WHERE pricelist_id = $1 + AND (product_id = $2 OR product_category_id = (SELECT category_id FROM inventory.products WHERE id = $2)) + AND active = true + AND min_quantity <= $3 + AND (valid_from IS NULL OR valid_from <= CURRENT_DATE) + AND (valid_to IS NULL OR valid_to >= CURRENT_DATE) + ORDER BY product_id NULLS LAST, min_quantity DESC + LIMIT 1`, + [pricelistId, productId, quantity] + ); + + return item?.price || null; + } +} + +export const pricelistsService = new PricelistsService(); diff --git a/src/modules/sales/quotations.service.ts b/src/modules/sales/quotations.service.ts new file mode 100644 index 0000000..9485e14 --- /dev/null +++ b/src/modules/sales/quotations.service.ts @@ -0,0 +1,588 @@ +import { query, queryOne, getClient } from '../../config/database.js'; +import { NotFoundError, ConflictError, ValidationError } from '../../shared/errors/index.js'; +import { taxesService } from '../financial/taxes.service.js'; + +export interface QuotationLine { + id: string; + quotation_id: string; + product_id?: string; + product_name?: string; + description: string; + quantity: number; + uom_id: string; + uom_name?: string; + price_unit: number; + discount: number; + tax_ids: string[]; + amount_untaxed: number; + amount_tax: number; + amount_total: number; +} + +export interface Quotation { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + name: string; + partner_id: string; + partner_name?: string; + quotation_date: Date; + validity_date: Date; + currency_id: string; + currency_code?: string; + pricelist_id?: string; + pricelist_name?: string; + user_id?: string; + user_name?: string; + sales_team_id?: string; + sales_team_name?: string; + amount_untaxed: number; + amount_tax: number; + amount_total: number; + status: 'draft' | 'sent' | 'confirmed' | 'cancelled' | 'expired'; + sale_order_id?: string; + notes?: string; + terms_conditions?: string; + lines?: QuotationLine[]; + created_at: Date; +} + +export interface CreateQuotationDto { + company_id: string; + partner_id: string; + quotation_date?: string; + validity_date: string; + currency_id: string; + pricelist_id?: string; + sales_team_id?: string; + notes?: string; + terms_conditions?: string; +} + +export interface UpdateQuotationDto { + partner_id?: string; + quotation_date?: string; + validity_date?: string; + currency_id?: string; + pricelist_id?: string | null; + sales_team_id?: string | null; + notes?: string | null; + terms_conditions?: string | null; +} + +export interface CreateQuotationLineDto { + product_id?: string; + description: string; + quantity: number; + uom_id: string; + price_unit: number; + discount?: number; + tax_ids?: string[]; +} + +export interface UpdateQuotationLineDto { + description?: string; + quantity?: number; + uom_id?: string; + price_unit?: number; + discount?: number; + tax_ids?: string[]; +} + +export interface QuotationFilters { + company_id?: string; + partner_id?: string; + status?: string; + date_from?: string; + date_to?: string; + search?: string; + page?: number; + limit?: number; +} + +class QuotationsService { + async findAll(tenantId: string, filters: QuotationFilters = {}): Promise<{ data: Quotation[]; total: number }> { + const { company_id, partner_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE q.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND q.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (partner_id) { + whereClause += ` AND q.partner_id = $${paramIndex++}`; + params.push(partner_id); + } + + if (status) { + whereClause += ` AND q.status = $${paramIndex++}`; + params.push(status); + } + + if (date_from) { + whereClause += ` AND q.quotation_date >= $${paramIndex++}`; + params.push(date_from); + } + + if (date_to) { + whereClause += ` AND q.quotation_date <= $${paramIndex++}`; + params.push(date_to); + } + + if (search) { + whereClause += ` AND (q.name ILIKE $${paramIndex} OR p.name ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count + FROM sales.quotations q + LEFT JOIN core.partners p ON q.partner_id = p.id + ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT q.*, + c.name as company_name, + p.name as partner_name, + cu.code as currency_code, + pl.name as pricelist_name, + u.name as user_name, + st.name as sales_team_name + FROM sales.quotations q + LEFT JOIN auth.companies c ON q.company_id = c.id + LEFT JOIN core.partners p ON q.partner_id = p.id + LEFT JOIN core.currencies cu ON q.currency_id = cu.id + LEFT JOIN sales.pricelists pl ON q.pricelist_id = pl.id + LEFT JOIN auth.users u ON q.user_id = u.id + LEFT JOIN sales.sales_teams st ON q.sales_team_id = st.id + ${whereClause} + ORDER BY q.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const quotation = await queryOne( + `SELECT q.*, + c.name as company_name, + p.name as partner_name, + cu.code as currency_code, + pl.name as pricelist_name, + u.name as user_name, + st.name as sales_team_name + FROM sales.quotations q + LEFT JOIN auth.companies c ON q.company_id = c.id + LEFT JOIN core.partners p ON q.partner_id = p.id + LEFT JOIN core.currencies cu ON q.currency_id = cu.id + LEFT JOIN sales.pricelists pl ON q.pricelist_id = pl.id + LEFT JOIN auth.users u ON q.user_id = u.id + LEFT JOIN sales.sales_teams st ON q.sales_team_id = st.id + WHERE q.id = $1 AND q.tenant_id = $2`, + [id, tenantId] + ); + + if (!quotation) { + throw new NotFoundError('Cotización no encontrada'); + } + + // Get lines + const lines = await query( + `SELECT ql.*, + pr.name as product_name, + um.name as uom_name + FROM sales.quotation_lines ql + LEFT JOIN inventory.products pr ON ql.product_id = pr.id + LEFT JOIN core.uom um ON ql.uom_id = um.id + WHERE ql.quotation_id = $1 + ORDER BY ql.created_at`, + [id] + ); + + quotation.lines = lines; + + return quotation; + } + + async create(dto: CreateQuotationDto, tenantId: string, userId: string): Promise { + // Generate sequence number + const seqResult = await queryOne<{ next_num: number }>( + `SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 5) AS INTEGER)), 0) + 1 as next_num + FROM sales.quotations WHERE tenant_id = $1 AND name LIKE 'QUO-%'`, + [tenantId] + ); + const quotationNumber = `QUO-${String(seqResult?.next_num || 1).padStart(6, '0')}`; + + const quotationDate = dto.quotation_date || new Date().toISOString().split('T')[0]; + + const quotation = await queryOne( + `INSERT INTO sales.quotations ( + tenant_id, company_id, name, partner_id, quotation_date, validity_date, + currency_id, pricelist_id, user_id, sales_team_id, notes, terms_conditions, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING *`, + [ + tenantId, dto.company_id, quotationNumber, dto.partner_id, + quotationDate, dto.validity_date, dto.currency_id, dto.pricelist_id, + userId, dto.sales_team_id, dto.notes, dto.terms_conditions, userId + ] + ); + + return quotation!; + } + + async update(id: string, dto: UpdateQuotationDto, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'draft') { + throw new ValidationError('Solo se pueden editar cotizaciones en estado borrador'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.partner_id !== undefined) { + updateFields.push(`partner_id = $${paramIndex++}`); + values.push(dto.partner_id); + } + if (dto.quotation_date !== undefined) { + updateFields.push(`quotation_date = $${paramIndex++}`); + values.push(dto.quotation_date); + } + if (dto.validity_date !== undefined) { + updateFields.push(`validity_date = $${paramIndex++}`); + values.push(dto.validity_date); + } + if (dto.currency_id !== undefined) { + updateFields.push(`currency_id = $${paramIndex++}`); + values.push(dto.currency_id); + } + if (dto.pricelist_id !== undefined) { + updateFields.push(`pricelist_id = $${paramIndex++}`); + values.push(dto.pricelist_id); + } + if (dto.sales_team_id !== undefined) { + updateFields.push(`sales_team_id = $${paramIndex++}`); + values.push(dto.sales_team_id); + } + if (dto.notes !== undefined) { + updateFields.push(`notes = $${paramIndex++}`); + values.push(dto.notes); + } + if (dto.terms_conditions !== undefined) { + updateFields.push(`terms_conditions = $${paramIndex++}`); + values.push(dto.terms_conditions); + } + + if (updateFields.length === 0) { + return existing; + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + await query( + `UPDATE sales.quotations SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async delete(id: string, tenantId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'draft') { + throw new ValidationError('Solo se pueden eliminar cotizaciones en estado borrador'); + } + + await query( + `DELETE FROM sales.quotations WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + } + + async addLine(quotationId: string, dto: CreateQuotationLineDto, tenantId: string, userId: string): Promise { + const quotation = await this.findById(quotationId, tenantId); + + if (quotation.status !== 'draft') { + throw new ValidationError('Solo se pueden agregar líneas a cotizaciones en estado borrador'); + } + + // Calculate amounts with taxes using taxesService + const taxResult = await taxesService.calculateTaxes( + { + quantity: dto.quantity, + priceUnit: dto.price_unit, + discount: dto.discount || 0, + taxIds: dto.tax_ids || [], + }, + tenantId, + 'sales' + ); + const amountUntaxed = taxResult.amountUntaxed; + const amountTax = taxResult.amountTax; + const amountTotal = taxResult.amountTotal; + + const line = await queryOne( + `INSERT INTO sales.quotation_lines ( + quotation_id, tenant_id, product_id, description, quantity, uom_id, + price_unit, discount, tax_ids, amount_untaxed, amount_tax, amount_total + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + RETURNING *`, + [ + quotationId, tenantId, dto.product_id, dto.description, dto.quantity, dto.uom_id, + dto.price_unit, dto.discount || 0, dto.tax_ids || [], amountUntaxed, amountTax, amountTotal + ] + ); + + // Update quotation totals + await this.updateTotals(quotationId); + + return line!; + } + + async updateLine(quotationId: string, lineId: string, dto: UpdateQuotationLineDto, tenantId: string): Promise { + const quotation = await this.findById(quotationId, tenantId); + + if (quotation.status !== 'draft') { + throw new ValidationError('Solo se pueden editar líneas de cotizaciones en estado borrador'); + } + + const existingLine = quotation.lines?.find(l => l.id === lineId); + if (!existingLine) { + throw new NotFoundError('Línea de cotización no encontrada'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + const quantity = dto.quantity ?? existingLine.quantity; + const priceUnit = dto.price_unit ?? existingLine.price_unit; + const discount = dto.discount ?? existingLine.discount; + + if (dto.description !== undefined) { + updateFields.push(`description = $${paramIndex++}`); + values.push(dto.description); + } + if (dto.quantity !== undefined) { + updateFields.push(`quantity = $${paramIndex++}`); + values.push(dto.quantity); + } + if (dto.uom_id !== undefined) { + updateFields.push(`uom_id = $${paramIndex++}`); + values.push(dto.uom_id); + } + if (dto.price_unit !== undefined) { + updateFields.push(`price_unit = $${paramIndex++}`); + values.push(dto.price_unit); + } + if (dto.discount !== undefined) { + updateFields.push(`discount = $${paramIndex++}`); + values.push(dto.discount); + } + if (dto.tax_ids !== undefined) { + updateFields.push(`tax_ids = $${paramIndex++}`); + values.push(dto.tax_ids); + } + + // Recalculate amounts + const subtotal = quantity * priceUnit; + const discountAmount = subtotal * discount / 100; + const amountUntaxed = subtotal - discountAmount; + const amountTax = 0; // TODO: Calculate taxes + const amountTotal = amountUntaxed + amountTax; + + updateFields.push(`amount_untaxed = $${paramIndex++}`); + values.push(amountUntaxed); + updateFields.push(`amount_tax = $${paramIndex++}`); + values.push(amountTax); + updateFields.push(`amount_total = $${paramIndex++}`); + values.push(amountTotal); + + values.push(lineId, quotationId); + + await query( + `UPDATE sales.quotation_lines SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND quotation_id = $${paramIndex}`, + values + ); + + // Update quotation totals + await this.updateTotals(quotationId); + + const updated = await queryOne( + `SELECT * FROM sales.quotation_lines WHERE id = $1`, + [lineId] + ); + + return updated!; + } + + async removeLine(quotationId: string, lineId: string, tenantId: string): Promise { + const quotation = await this.findById(quotationId, tenantId); + + if (quotation.status !== 'draft') { + throw new ValidationError('Solo se pueden eliminar líneas de cotizaciones en estado borrador'); + } + + await query( + `DELETE FROM sales.quotation_lines WHERE id = $1 AND quotation_id = $2`, + [lineId, quotationId] + ); + + // Update quotation totals + await this.updateTotals(quotationId); + } + + async send(id: string, tenantId: string, userId: string): Promise { + const quotation = await this.findById(id, tenantId); + + if (quotation.status !== 'draft') { + throw new ValidationError('Solo se pueden enviar cotizaciones en estado borrador'); + } + + if (!quotation.lines || quotation.lines.length === 0) { + throw new ValidationError('La cotización debe tener al menos una línea'); + } + + await query( + `UPDATE sales.quotations SET status = 'sent', updated_by = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + // TODO: Send email notification + + return this.findById(id, tenantId); + } + + async confirm(id: string, tenantId: string, userId: string): Promise<{ quotation: Quotation; orderId: string }> { + const quotation = await this.findById(id, tenantId); + + if (!['draft', 'sent'].includes(quotation.status)) { + throw new ValidationError('Solo se pueden confirmar cotizaciones en estado borrador o enviado'); + } + + if (!quotation.lines || quotation.lines.length === 0) { + throw new ValidationError('La cotización debe tener al menos una línea'); + } + + const client = await getClient(); + try { + await client.query('BEGIN'); + + // Generate order sequence number + const seqResult = await client.query( + `SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 4) AS INTEGER)), 0) + 1 as next_num + FROM sales.sales_orders WHERE tenant_id = $1 AND name LIKE 'SO-%'`, + [tenantId] + ); + const orderNumber = `SO-${String(seqResult.rows[0]?.next_num || 1).padStart(6, '0')}`; + + // Create sales order + const orderResult = await client.query( + `INSERT INTO sales.sales_orders ( + tenant_id, company_id, name, partner_id, order_date, currency_id, + pricelist_id, user_id, sales_team_id, amount_untaxed, amount_tax, + amount_total, notes, terms_conditions, created_by + ) + SELECT tenant_id, company_id, $1, partner_id, CURRENT_DATE, currency_id, + pricelist_id, user_id, sales_team_id, amount_untaxed, amount_tax, + amount_total, notes, terms_conditions, $2 + FROM sales.quotations WHERE id = $3 + RETURNING id`, + [orderNumber, userId, id] + ); + const orderId = orderResult.rows[0].id; + + // Copy lines to order (include tenant_id for multi-tenant security) + await client.query( + `INSERT INTO sales.sales_order_lines ( + order_id, tenant_id, product_id, description, quantity, uom_id, price_unit, + discount, tax_ids, amount_untaxed, amount_tax, amount_total + ) + SELECT $1, $3, product_id, description, quantity, uom_id, price_unit, + discount, tax_ids, amount_untaxed, amount_tax, amount_total + FROM sales.quotation_lines WHERE quotation_id = $2 AND tenant_id = $3`, + [orderId, id, tenantId] + ); + + // Update quotation status + await client.query( + `UPDATE sales.quotations SET status = 'confirmed', sale_order_id = $1, + updated_by = $2, updated_at = CURRENT_TIMESTAMP + WHERE id = $3`, + [orderId, userId, id] + ); + + await client.query('COMMIT'); + + return { + quotation: await this.findById(id, tenantId), + orderId + }; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + async cancel(id: string, tenantId: string, userId: string): Promise { + const quotation = await this.findById(id, tenantId); + + if (quotation.status === 'confirmed') { + throw new ValidationError('No se pueden cancelar cotizaciones confirmadas'); + } + + if (quotation.status === 'cancelled') { + throw new ValidationError('La cotización ya está cancelada'); + } + + await query( + `UPDATE sales.quotations SET status = 'cancelled', updated_by = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2 AND tenant_id = $3`, + [userId, id, tenantId] + ); + + return this.findById(id, tenantId); + } + + private async updateTotals(quotationId: string): Promise { + await query( + `UPDATE sales.quotations SET + amount_untaxed = COALESCE((SELECT SUM(amount_untaxed) FROM sales.quotation_lines WHERE quotation_id = $1), 0), + amount_tax = COALESCE((SELECT SUM(amount_tax) FROM sales.quotation_lines WHERE quotation_id = $1), 0), + amount_total = COALESCE((SELECT SUM(amount_total) FROM sales.quotation_lines WHERE quotation_id = $1), 0) + WHERE id = $1`, + [quotationId] + ); + } +} + +export const quotationsService = new QuotationsService(); diff --git a/src/modules/sales/sales-teams.service.ts b/src/modules/sales/sales-teams.service.ts new file mode 100644 index 0000000..b9185b5 --- /dev/null +++ b/src/modules/sales/sales-teams.service.ts @@ -0,0 +1,241 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ConflictError } from '../../shared/errors/index.js'; + +export interface SalesTeamMember { + id: string; + sales_team_id: string; + user_id: string; + user_name?: string; + user_email?: string; + role?: string; + joined_at: Date; +} + +export interface SalesTeam { + id: string; + tenant_id: string; + company_id: string; + company_name?: string; + name: string; + code?: string; + team_leader_id?: string; + team_leader_name?: string; + target_monthly?: number; + target_annual?: number; + active: boolean; + members?: SalesTeamMember[]; + created_at: Date; +} + +export interface CreateSalesTeamDto { + company_id: string; + name: string; + code?: string; + team_leader_id?: string; + target_monthly?: number; + target_annual?: number; +} + +export interface UpdateSalesTeamDto { + name?: string; + code?: string; + team_leader_id?: string | null; + target_monthly?: number | null; + target_annual?: number | null; + active?: boolean; +} + +export interface SalesTeamFilters { + company_id?: string; + active?: boolean; + page?: number; + limit?: number; +} + +class SalesTeamsService { + async findAll(tenantId: string, filters: SalesTeamFilters = {}): Promise<{ data: SalesTeam[]; total: number }> { + const { company_id, active, page = 1, limit = 20 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE st.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (company_id) { + whereClause += ` AND st.company_id = $${paramIndex++}`; + params.push(company_id); + } + + if (active !== undefined) { + whereClause += ` AND st.active = $${paramIndex++}`; + params.push(active); + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM sales.sales_teams st ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT st.*, + c.name as company_name, + u.full_name as team_leader_name + FROM sales.sales_teams st + LEFT JOIN auth.companies c ON st.company_id = c.id + LEFT JOIN auth.users u ON st.team_leader_id = u.id + ${whereClause} + ORDER BY st.name + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findById(id: string, tenantId: string): Promise { + const team = await queryOne( + `SELECT st.*, + c.name as company_name, + u.full_name as team_leader_name + FROM sales.sales_teams st + LEFT JOIN auth.companies c ON st.company_id = c.id + LEFT JOIN auth.users u ON st.team_leader_id = u.id + WHERE st.id = $1 AND st.tenant_id = $2`, + [id, tenantId] + ); + + if (!team) { + throw new NotFoundError('Equipo de ventas no encontrado'); + } + + // Get members + const members = await query( + `SELECT stm.*, + u.full_name as user_name, + u.email as user_email + FROM sales.sales_team_members stm + LEFT JOIN auth.users u ON stm.user_id = u.id + WHERE stm.sales_team_id = $1 + ORDER BY stm.joined_at`, + [id] + ); + + team.members = members; + + return team; + } + + async create(dto: CreateSalesTeamDto, tenantId: string, userId: string): Promise { + // Check unique code in company + if (dto.code) { + const existing = await queryOne( + `SELECT id FROM sales.sales_teams WHERE company_id = $1 AND code = $2`, + [dto.company_id, dto.code] + ); + if (existing) { + throw new ConflictError('Ya existe un equipo con ese código en esta empresa'); + } + } + + const team = await queryOne( + `INSERT INTO sales.sales_teams (tenant_id, company_id, name, code, team_leader_id, target_monthly, target_annual, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING *`, + [tenantId, dto.company_id, dto.name, dto.code, dto.team_leader_id, dto.target_monthly, dto.target_annual, userId] + ); + + return team!; + } + + async update(id: string, dto: UpdateSalesTeamDto, tenantId: string, userId: string): Promise { + const team = await this.findById(id, tenantId); + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.name !== undefined) { + updateFields.push(`name = $${paramIndex++}`); + values.push(dto.name); + } + if (dto.code !== undefined) { + // Check unique code + const existing = await queryOne( + `SELECT id FROM sales.sales_teams WHERE company_id = $1 AND code = $2 AND id != $3`, + [team.company_id, dto.code, id] + ); + if (existing) { + throw new ConflictError('Ya existe un equipo con ese código en esta empresa'); + } + updateFields.push(`code = $${paramIndex++}`); + values.push(dto.code); + } + if (dto.team_leader_id !== undefined) { + updateFields.push(`team_leader_id = $${paramIndex++}`); + values.push(dto.team_leader_id); + } + if (dto.target_monthly !== undefined) { + updateFields.push(`target_monthly = $${paramIndex++}`); + values.push(dto.target_monthly); + } + if (dto.target_annual !== undefined) { + updateFields.push(`target_annual = $${paramIndex++}`); + values.push(dto.target_annual); + } + if (dto.active !== undefined) { + updateFields.push(`active = $${paramIndex++}`); + values.push(dto.active); + } + + updateFields.push(`updated_by = $${paramIndex++}`); + values.push(userId); + updateFields.push(`updated_at = CURRENT_TIMESTAMP`); + + values.push(id, tenantId); + + await query( + `UPDATE sales.sales_teams SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async addMember(teamId: string, userId: string, role: string, tenantId: string): Promise { + await this.findById(teamId, tenantId); + + // Check if already member + const existing = await queryOne( + `SELECT id FROM sales.sales_team_members WHERE sales_team_id = $1 AND user_id = $2`, + [teamId, userId] + ); + if (existing) { + throw new ConflictError('El usuario ya es miembro de este equipo'); + } + + const member = await queryOne( + `INSERT INTO sales.sales_team_members (sales_team_id, user_id, role) + VALUES ($1, $2, $3) + RETURNING *`, + [teamId, userId, role] + ); + + return member!; + } + + async removeMember(teamId: string, memberId: string, tenantId: string): Promise { + await this.findById(teamId, tenantId); + + await query( + `DELETE FROM sales.sales_team_members WHERE id = $1 AND sales_team_id = $2`, + [memberId, teamId] + ); + } +} + +export const salesTeamsService = new SalesTeamsService(); diff --git a/src/modules/sales/sales.controller.ts b/src/modules/sales/sales.controller.ts new file mode 100644 index 0000000..efd8a83 --- /dev/null +++ b/src/modules/sales/sales.controller.ts @@ -0,0 +1,889 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { pricelistsService, CreatePricelistDto, UpdatePricelistDto, CreatePricelistItemDto, PricelistFilters } from './pricelists.service.js'; +import { salesTeamsService, CreateSalesTeamDto, UpdateSalesTeamDto, SalesTeamFilters } from './sales-teams.service.js'; +import { customerGroupsService, CreateCustomerGroupDto, UpdateCustomerGroupDto, CustomerGroupFilters } from './customer-groups.service.js'; +import { quotationsService, CreateQuotationDto, UpdateQuotationDto, CreateQuotationLineDto, UpdateQuotationLineDto, QuotationFilters } from './quotations.service.js'; +import { ordersService, CreateSalesOrderDto, UpdateSalesOrderDto, CreateSalesOrderLineDto, UpdateSalesOrderLineDto, SalesOrderFilters } from './orders.service.js'; +import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js'; +import { ValidationError } from '../../shared/errors/index.js'; + +// Pricelist schemas +const createPricelistSchema = z.object({ + company_id: z.string().uuid().optional(), + name: z.string().min(1, 'El nombre es requerido').max(255), + currency_id: z.string().uuid({ message: 'La moneda es requerida' }), +}); + +const updatePricelistSchema = z.object({ + name: z.string().min(1).max(255).optional(), + currency_id: z.string().uuid().optional(), + active: z.boolean().optional(), +}); + +const createPricelistItemSchema = z.object({ + product_id: z.string().uuid().optional(), + product_category_id: z.string().uuid().optional(), + price: z.number().min(0, 'El precio debe ser positivo'), + min_quantity: z.number().positive().default(1), + valid_from: z.string().optional(), + valid_to: z.string().optional(), +}); + +const pricelistQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + active: z.coerce.boolean().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +// Sales Team schemas +const createSalesTeamSchema = z.object({ + company_id: z.string().uuid({ message: 'La empresa es requerida' }), + name: z.string().min(1, 'El nombre es requerido').max(255), + code: z.string().max(50).optional(), + team_leader_id: z.string().uuid().optional(), + target_monthly: z.number().positive().optional(), + target_annual: z.number().positive().optional(), +}); + +const updateSalesTeamSchema = z.object({ + name: z.string().min(1).max(255).optional(), + code: z.string().max(50).optional(), + team_leader_id: z.string().uuid().optional().nullable(), + target_monthly: z.number().positive().optional().nullable(), + target_annual: z.number().positive().optional().nullable(), + active: z.boolean().optional(), +}); + +const addTeamMemberSchema = z.object({ + user_id: z.string().uuid({ message: 'El usuario es requerido' }), + role: z.string().max(100).default('member'), +}); + +const salesTeamQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + active: z.coerce.boolean().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +// Customer Group schemas +const createCustomerGroupSchema = z.object({ + name: z.string().min(1, 'El nombre es requerido').max(255), + description: z.string().optional(), + discount_percentage: z.number().min(0).max(100).default(0), +}); + +const updateCustomerGroupSchema = z.object({ + name: z.string().min(1).max(255).optional(), + description: z.string().optional().nullable(), + discount_percentage: z.number().min(0).max(100).optional(), +}); + +const addGroupMemberSchema = z.object({ + partner_id: z.string().uuid({ message: 'El cliente es requerido' }), +}); + +const customerGroupQuerySchema = z.object({ + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +// Quotation schemas +const createQuotationSchema = z.object({ + company_id: z.string().uuid({ message: 'La empresa es requerida' }), + partner_id: z.string().uuid({ message: 'El cliente es requerido' }), + quotation_date: z.string().optional(), + validity_date: z.string({ message: 'La fecha de validez es requerida' }), + currency_id: z.string().uuid({ message: 'La moneda es requerida' }), + pricelist_id: z.string().uuid().optional(), + sales_team_id: z.string().uuid().optional(), + notes: z.string().optional(), + terms_conditions: z.string().optional(), +}); + +const updateQuotationSchema = z.object({ + partner_id: z.string().uuid().optional(), + quotation_date: z.string().optional(), + validity_date: z.string().optional(), + currency_id: z.string().uuid().optional(), + pricelist_id: z.string().uuid().optional().nullable(), + sales_team_id: z.string().uuid().optional().nullable(), + notes: z.string().optional().nullable(), + terms_conditions: z.string().optional().nullable(), +}); + +const createQuotationLineSchema = z.object({ + product_id: z.string().uuid().optional(), + description: z.string().min(1, 'La descripción es requerida'), + quantity: z.number().positive('La cantidad debe ser positiva'), + uom_id: z.string().uuid({ message: 'La unidad de medida es requerida' }), + price_unit: z.number().min(0, 'El precio debe ser positivo'), + discount: z.number().min(0).max(100).default(0), + tax_ids: z.array(z.string().uuid()).optional(), +}); + +const updateQuotationLineSchema = z.object({ + description: z.string().min(1).optional(), + quantity: z.number().positive().optional(), + uom_id: z.string().uuid().optional(), + price_unit: z.number().min(0).optional(), + discount: z.number().min(0).max(100).optional(), + tax_ids: z.array(z.string().uuid()).optional(), +}); + +const quotationQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + partner_id: z.string().uuid().optional(), + status: z.enum(['draft', 'sent', 'confirmed', 'cancelled', 'expired']).optional(), + date_from: z.string().optional(), + date_to: z.string().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +// Sales Order schemas +const createSalesOrderSchema = z.object({ + company_id: z.string().uuid({ message: 'La empresa es requerida' }), + partner_id: z.string().uuid({ message: 'El cliente es requerido' }), + client_order_ref: z.string().max(100).optional(), + order_date: z.string().optional(), + validity_date: z.string().optional(), + commitment_date: z.string().optional(), + currency_id: z.string().uuid({ message: 'La moneda es requerida' }), + pricelist_id: z.string().uuid().optional(), + payment_term_id: z.string().uuid().optional(), + sales_team_id: z.string().uuid().optional(), + invoice_policy: z.enum(['order', 'delivery']).default('order'), + notes: z.string().optional(), + terms_conditions: z.string().optional(), +}); + +const updateSalesOrderSchema = z.object({ + partner_id: z.string().uuid().optional(), + client_order_ref: z.string().max(100).optional().nullable(), + order_date: z.string().optional(), + validity_date: z.string().optional().nullable(), + commitment_date: z.string().optional().nullable(), + currency_id: z.string().uuid().optional(), + pricelist_id: z.string().uuid().optional().nullable(), + payment_term_id: z.string().uuid().optional().nullable(), + sales_team_id: z.string().uuid().optional().nullable(), + invoice_policy: z.enum(['order', 'delivery']).optional(), + notes: z.string().optional().nullable(), + terms_conditions: z.string().optional().nullable(), +}); + +const createSalesOrderLineSchema = z.object({ + product_id: z.string().uuid({ message: 'El producto es requerido' }), + description: z.string().min(1, 'La descripción es requerida'), + quantity: z.number().positive('La cantidad debe ser positiva'), + uom_id: z.string().uuid({ message: 'La unidad de medida es requerida' }), + price_unit: z.number().min(0, 'El precio debe ser positivo'), + discount: z.number().min(0).max(100).default(0), + tax_ids: z.array(z.string().uuid()).optional(), + analytic_account_id: z.string().uuid().optional(), +}); + +const updateSalesOrderLineSchema = z.object({ + description: z.string().min(1).optional(), + quantity: z.number().positive().optional(), + uom_id: z.string().uuid().optional(), + price_unit: z.number().min(0).optional(), + discount: z.number().min(0).max(100).optional(), + tax_ids: z.array(z.string().uuid()).optional(), + analytic_account_id: z.string().uuid().optional().nullable(), +}); + +const salesOrderQuerySchema = z.object({ + company_id: z.string().uuid().optional(), + partner_id: z.string().uuid().optional(), + status: z.enum(['draft', 'sent', 'sale', 'done', 'cancelled']).optional(), + invoice_status: z.enum(['pending', 'partial', 'invoiced']).optional(), + delivery_status: z.enum(['pending', 'partial', 'delivered']).optional(), + date_from: z.string().optional(), + date_to: z.string().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(20), +}); + +class SalesController { + // ========== PRICELISTS ========== + async getPricelists(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = pricelistQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: PricelistFilters = queryResult.data; + const result = await pricelistsService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + async getPricelist(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const pricelist = await pricelistsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: pricelist }); + } catch (error) { + next(error); + } + } + + async createPricelist(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createPricelistSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de lista de precios inválidos', parseResult.error.errors); + } + + const dto: CreatePricelistDto = parseResult.data; + const pricelist = await pricelistsService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: pricelist, + message: 'Lista de precios creada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updatePricelist(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updatePricelistSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de lista de precios inválidos', parseResult.error.errors); + } + + const dto: UpdatePricelistDto = parseResult.data; + const pricelist = await pricelistsService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: pricelist, + message: 'Lista de precios actualizada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async addPricelistItem(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createPricelistItemSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de item inválidos', parseResult.error.errors); + } + + const dto: CreatePricelistItemDto = parseResult.data; + const item = await pricelistsService.addItem(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: item, + message: 'Item agregado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async removePricelistItem(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await pricelistsService.removeItem(req.params.id, req.params.itemId, req.tenantId!); + res.json({ success: true, message: 'Item eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== SALES TEAMS ========== + async getSalesTeams(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = salesTeamQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: SalesTeamFilters = queryResult.data; + const result = await salesTeamsService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + async getSalesTeam(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const team = await salesTeamsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: team }); + } catch (error) { + next(error); + } + } + + async createSalesTeam(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createSalesTeamSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de equipo de ventas inválidos', parseResult.error.errors); + } + + const dto: CreateSalesTeamDto = parseResult.data; + const team = await salesTeamsService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: team, + message: 'Equipo de ventas creado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateSalesTeam(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateSalesTeamSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de equipo de ventas inválidos', parseResult.error.errors); + } + + const dto: UpdateSalesTeamDto = parseResult.data; + const team = await salesTeamsService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: team, + message: 'Equipo de ventas actualizado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async addSalesTeamMember(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = addTeamMemberSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos inválidos', parseResult.error.errors); + } + + const member = await salesTeamsService.addMember( + req.params.id, + parseResult.data.user_id, + parseResult.data.role, + req.tenantId! + ); + + res.status(201).json({ + success: true, + data: member, + message: 'Miembro agregado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async removeSalesTeamMember(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await salesTeamsService.removeMember(req.params.id, req.params.memberId, req.tenantId!); + res.json({ success: true, message: 'Miembro eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== CUSTOMER GROUPS ========== + async getCustomerGroups(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = customerGroupQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: CustomerGroupFilters = queryResult.data; + const result = await customerGroupsService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + async getCustomerGroup(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const group = await customerGroupsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: group }); + } catch (error) { + next(error); + } + } + + async createCustomerGroup(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createCustomerGroupSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de grupo de clientes inválidos', parseResult.error.errors); + } + + const dto: CreateCustomerGroupDto = parseResult.data; + const group = await customerGroupsService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: group, + message: 'Grupo de clientes creado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateCustomerGroup(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateCustomerGroupSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de grupo de clientes inválidos', parseResult.error.errors); + } + + const dto: UpdateCustomerGroupDto = parseResult.data; + const group = await customerGroupsService.update(req.params.id, dto, req.tenantId!); + + res.json({ + success: true, + data: group, + message: 'Grupo de clientes actualizado exitosamente', + }); + } catch (error) { + next(error); + } + } + + async deleteCustomerGroup(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await customerGroupsService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Grupo de clientes eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + async addCustomerGroupMember(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = addGroupMemberSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos inválidos', parseResult.error.errors); + } + + const member = await customerGroupsService.addMember( + req.params.id, + parseResult.data.partner_id, + req.tenantId! + ); + + res.status(201).json({ + success: true, + data: member, + message: 'Cliente agregado al grupo exitosamente', + }); + } catch (error) { + next(error); + } + } + + async removeCustomerGroupMember(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await customerGroupsService.removeMember(req.params.id, req.params.memberId, req.tenantId!); + res.json({ success: true, message: 'Cliente eliminado del grupo exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== QUOTATIONS ========== + async getQuotations(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = quotationQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: QuotationFilters = queryResult.data; + const result = await quotationsService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + async getQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const quotation = await quotationsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: quotation }); + } catch (error) { + next(error); + } + } + + async createQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createQuotationSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de cotización inválidos', parseResult.error.errors); + } + + const dto: CreateQuotationDto = parseResult.data; + const quotation = await quotationsService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: quotation, + message: 'Cotización creada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateQuotationSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de cotización inválidos', parseResult.error.errors); + } + + const dto: UpdateQuotationDto = parseResult.data; + const quotation = await quotationsService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: quotation, + message: 'Cotización actualizada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async deleteQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await quotationsService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Cotización eliminada exitosamente' }); + } catch (error) { + next(error); + } + } + + async addQuotationLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createQuotationLineSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de línea inválidos', parseResult.error.errors); + } + + const dto: CreateQuotationLineDto = parseResult.data; + const line = await quotationsService.addLine(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: line, + message: 'Línea agregada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateQuotationLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateQuotationLineSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de línea inválidos', parseResult.error.errors); + } + + const dto: UpdateQuotationLineDto = parseResult.data; + const line = await quotationsService.updateLine(req.params.id, req.params.lineId, dto, req.tenantId!); + + res.json({ + success: true, + data: line, + message: 'Línea actualizada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async removeQuotationLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await quotationsService.removeLine(req.params.id, req.params.lineId, req.tenantId!); + res.json({ success: true, message: 'Línea eliminada exitosamente' }); + } catch (error) { + next(error); + } + } + + async sendQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const quotation = await quotationsService.send(req.params.id, req.tenantId!, req.user!.userId); + res.json({ + success: true, + data: quotation, + message: 'Cotización enviada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async confirmQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const result = await quotationsService.confirm(req.params.id, req.tenantId!, req.user!.userId); + res.json({ + success: true, + data: result.quotation, + orderId: result.orderId, + message: 'Cotización confirmada y orden de venta creada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async cancelQuotation(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const quotation = await quotationsService.cancel(req.params.id, req.tenantId!, req.user!.userId); + res.json({ + success: true, + data: quotation, + message: 'Cotización cancelada exitosamente', + }); + } catch (error) { + next(error); + } + } + + // ========== SALES ORDERS ========== + async getOrders(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = salesOrderQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + + const filters: SalesOrderFilters = queryResult.data; + const result = await ordersService.findAll(req.tenantId!, filters); + + res.json({ + success: true, + data: result.data, + meta: { + total: result.total, + page: filters.page, + limit: filters.limit, + totalPages: Math.ceil(result.total / (filters.limit || 20)), + }, + }); + } catch (error) { + next(error); + } + } + + async getOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const order = await ordersService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: order }); + } catch (error) { + next(error); + } + } + + async createOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createSalesOrderSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de orden inválidos', parseResult.error.errors); + } + + const dto: CreateSalesOrderDto = parseResult.data; + const order = await ordersService.create(dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: order, + message: 'Orden de venta creada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateSalesOrderSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de orden inválidos', parseResult.error.errors); + } + + const dto: UpdateSalesOrderDto = parseResult.data; + const order = await ordersService.update(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.json({ + success: true, + data: order, + message: 'Orden de venta actualizada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async deleteOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await ordersService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Orden de venta eliminada exitosamente' }); + } catch (error) { + next(error); + } + } + + async addOrderLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createSalesOrderLineSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de línea inválidos', parseResult.error.errors); + } + + const dto: CreateSalesOrderLineDto = parseResult.data; + const line = await ordersService.addLine(req.params.id, dto, req.tenantId!, req.user!.userId); + + res.status(201).json({ + success: true, + data: line, + message: 'Línea agregada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async updateOrderLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateSalesOrderLineSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de línea inválidos', parseResult.error.errors); + } + + const dto: UpdateSalesOrderLineDto = parseResult.data; + const line = await ordersService.updateLine(req.params.id, req.params.lineId, dto, req.tenantId!); + + res.json({ + success: true, + data: line, + message: 'Línea actualizada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async removeOrderLine(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await ordersService.removeLine(req.params.id, req.params.lineId, req.tenantId!); + res.json({ success: true, message: 'Línea eliminada exitosamente' }); + } catch (error) { + next(error); + } + } + + async confirmOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const order = await ordersService.confirm(req.params.id, req.tenantId!, req.user!.userId); + res.json({ + success: true, + data: order, + message: 'Orden de venta confirmada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async cancelOrder(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const order = await ordersService.cancel(req.params.id, req.tenantId!, req.user!.userId); + res.json({ + success: true, + data: order, + message: 'Orden de venta cancelada exitosamente', + }); + } catch (error) { + next(error); + } + } + + async createOrderInvoice(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const result = await ordersService.createInvoice(req.params.id, req.tenantId!, req.user!.userId); + res.status(201).json({ + success: true, + data: result, + message: 'Factura creada exitosamente', + }); + } catch (error) { + next(error); + } + } +} + +export const salesController = new SalesController(); diff --git a/src/modules/sales/sales.routes.ts b/src/modules/sales/sales.routes.ts new file mode 100644 index 0000000..6da9632 --- /dev/null +++ b/src/modules/sales/sales.routes.ts @@ -0,0 +1,159 @@ +import { Router } from 'express'; +import { salesController } from './sales.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// ========== PRICELISTS ========== +router.get('/pricelists', (req, res, next) => salesController.getPricelists(req, res, next)); + +router.get('/pricelists/:id', (req, res, next) => salesController.getPricelist(req, res, next)); + +router.post('/pricelists', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.createPricelist(req, res, next) +); + +router.put('/pricelists/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.updatePricelist(req, res, next) +); + +router.post('/pricelists/:id/items', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.addPricelistItem(req, res, next) +); + +router.delete('/pricelists/:id/items/:itemId', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.removePricelistItem(req, res, next) +); + +// ========== SALES TEAMS ========== +router.get('/teams', (req, res, next) => salesController.getSalesTeams(req, res, next)); + +router.get('/teams/:id', (req, res, next) => salesController.getSalesTeam(req, res, next)); + +router.post('/teams', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.createSalesTeam(req, res, next) +); + +router.put('/teams/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.updateSalesTeam(req, res, next) +); + +router.post('/teams/:id/members', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.addSalesTeamMember(req, res, next) +); + +router.delete('/teams/:id/members/:memberId', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.removeSalesTeamMember(req, res, next) +); + +// ========== CUSTOMER GROUPS ========== +router.get('/customer-groups', (req, res, next) => salesController.getCustomerGroups(req, res, next)); + +router.get('/customer-groups/:id', (req, res, next) => salesController.getCustomerGroup(req, res, next)); + +router.post('/customer-groups', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.createCustomerGroup(req, res, next) +); + +router.put('/customer-groups/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.updateCustomerGroup(req, res, next) +); + +router.delete('/customer-groups/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + salesController.deleteCustomerGroup(req, res, next) +); + +router.post('/customer-groups/:id/members', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.addCustomerGroupMember(req, res, next) +); + +router.delete('/customer-groups/:id/members/:memberId', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.removeCustomerGroupMember(req, res, next) +); + +// ========== QUOTATIONS ========== +router.get('/quotations', (req, res, next) => salesController.getQuotations(req, res, next)); + +router.get('/quotations/:id', (req, res, next) => salesController.getQuotation(req, res, next)); + +router.post('/quotations', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.createQuotation(req, res, next) +); + +router.put('/quotations/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.updateQuotation(req, res, next) +); + +router.delete('/quotations/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.deleteQuotation(req, res, next) +); + +router.post('/quotations/:id/lines', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.addQuotationLine(req, res, next) +); + +router.put('/quotations/:id/lines/:lineId', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.updateQuotationLine(req, res, next) +); + +router.delete('/quotations/:id/lines/:lineId', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.removeQuotationLine(req, res, next) +); + +router.post('/quotations/:id/send', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.sendQuotation(req, res, next) +); + +router.post('/quotations/:id/confirm', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.confirmQuotation(req, res, next) +); + +router.post('/quotations/:id/cancel', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.cancelQuotation(req, res, next) +); + +// ========== SALES ORDERS ========== +router.get('/orders', (req, res, next) => salesController.getOrders(req, res, next)); + +router.get('/orders/:id', (req, res, next) => salesController.getOrder(req, res, next)); + +router.post('/orders', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.createOrder(req, res, next) +); + +router.put('/orders/:id', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.updateOrder(req, res, next) +); + +router.delete('/orders/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.deleteOrder(req, res, next) +); + +router.post('/orders/:id/lines', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.addOrderLine(req, res, next) +); + +router.put('/orders/:id/lines/:lineId', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.updateOrderLine(req, res, next) +); + +router.delete('/orders/:id/lines/:lineId', requireRoles('admin', 'manager', 'sales', 'super_admin'), (req, res, next) => + salesController.removeOrderLine(req, res, next) +); + +router.post('/orders/:id/confirm', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.confirmOrder(req, res, next) +); + +router.post('/orders/:id/cancel', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + salesController.cancelOrder(req, res, next) +); + +router.post('/orders/:id/invoice', requireRoles('admin', 'manager', 'accountant', 'super_admin'), (req, res, next) => + salesController.createOrderInvoice(req, res, next) +); + +export default router; diff --git a/src/modules/system/activities.service.ts b/src/modules/system/activities.service.ts new file mode 100644 index 0000000..abdce3e --- /dev/null +++ b/src/modules/system/activities.service.ts @@ -0,0 +1,350 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../../shared/errors/index.js'; + +export interface Activity { + id: string; + tenant_id: string; + model: string; + record_id: string; + activity_type: 'call' | 'meeting' | 'email' | 'todo' | 'follow_up' | 'custom'; + summary: string; + description?: string; + assigned_to?: string; + assigned_to_name?: string; + assigned_by?: string; + assigned_by_name?: string; + due_date: Date; + due_time?: string; + status: 'planned' | 'done' | 'cancelled' | 'overdue'; + created_at: Date; + created_by?: string; + completed_at?: Date; + completed_by?: string; +} + +export interface CreateActivityDto { + model: string; + record_id: string; + activity_type: 'call' | 'meeting' | 'email' | 'todo' | 'follow_up' | 'custom'; + summary: string; + description?: string; + assigned_to?: string; + due_date: string; + due_time?: string; +} + +export interface UpdateActivityDto { + activity_type?: 'call' | 'meeting' | 'email' | 'todo' | 'follow_up' | 'custom'; + summary?: string; + description?: string | null; + assigned_to?: string | null; + due_date?: string; + due_time?: string | null; +} + +export interface ActivityFilters { + model?: string; + record_id?: string; + activity_type?: string; + assigned_to?: string; + status?: string; + due_from?: string; + due_to?: string; + overdue_only?: boolean; + search?: string; + page?: number; + limit?: number; +} + +class ActivitiesService { + async findAll(tenantId: string, filters: ActivityFilters = {}): Promise<{ data: Activity[]; total: number }> { + const { model, record_id, activity_type, assigned_to, status, due_from, due_to, overdue_only, search, page = 1, limit = 50 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE a.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (model) { + whereClause += ` AND a.model = $${paramIndex++}`; + params.push(model); + } + + if (record_id) { + whereClause += ` AND a.record_id = $${paramIndex++}`; + params.push(record_id); + } + + if (activity_type) { + whereClause += ` AND a.activity_type = $${paramIndex++}`; + params.push(activity_type); + } + + if (assigned_to) { + whereClause += ` AND a.assigned_to = $${paramIndex++}`; + params.push(assigned_to); + } + + if (status) { + whereClause += ` AND a.status = $${paramIndex++}`; + params.push(status); + } + + if (due_from) { + whereClause += ` AND a.due_date >= $${paramIndex++}`; + params.push(due_from); + } + + if (due_to) { + whereClause += ` AND a.due_date <= $${paramIndex++}`; + params.push(due_to); + } + + if (overdue_only) { + whereClause += ` AND a.status = 'planned' AND a.due_date < CURRENT_DATE`; + } + + if (search) { + whereClause += ` AND (a.summary ILIKE $${paramIndex} OR a.description ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM system.activities a ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT a.*, + uto.first_name || ' ' || uto.last_name as assigned_to_name, + uby.first_name || ' ' || uby.last_name as assigned_by_name + FROM system.activities a + LEFT JOIN auth.users uto ON a.assigned_to = uto.id + LEFT JOIN auth.users uby ON a.assigned_by = uby.id + ${whereClause} + ORDER BY a.due_date ASC, a.due_time ASC NULLS LAST, a.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findByRecord(model: string, recordId: string, tenantId: string): Promise { + const activities = await query( + `SELECT a.*, + uto.first_name || ' ' || uto.last_name as assigned_to_name, + uby.first_name || ' ' || uby.last_name as assigned_by_name + FROM system.activities a + LEFT JOIN auth.users uto ON a.assigned_to = uto.id + LEFT JOIN auth.users uby ON a.assigned_by = uby.id + WHERE a.model = $1 AND a.record_id = $2 AND a.tenant_id = $3 + ORDER BY a.due_date ASC, a.due_time ASC NULLS LAST`, + [model, recordId, tenantId] + ); + + return activities; + } + + async findByUser(userId: string, tenantId: string, status?: string): Promise { + let whereClause = 'WHERE a.assigned_to = $1 AND a.tenant_id = $2'; + const params: any[] = [userId, tenantId]; + + if (status) { + whereClause += ' AND a.status = $3'; + params.push(status); + } + + const activities = await query( + `SELECT a.*, + uto.first_name || ' ' || uto.last_name as assigned_to_name, + uby.first_name || ' ' || uby.last_name as assigned_by_name + FROM system.activities a + LEFT JOIN auth.users uto ON a.assigned_to = uto.id + LEFT JOIN auth.users uby ON a.assigned_by = uby.id + ${whereClause} + ORDER BY a.due_date ASC, a.due_time ASC NULLS LAST`, + params + ); + + return activities; + } + + async findById(id: string, tenantId: string): Promise { + const activity = await queryOne( + `SELECT a.*, + uto.first_name || ' ' || uto.last_name as assigned_to_name, + uby.first_name || ' ' || uby.last_name as assigned_by_name + FROM system.activities a + LEFT JOIN auth.users uto ON a.assigned_to = uto.id + LEFT JOIN auth.users uby ON a.assigned_by = uby.id + WHERE a.id = $1 AND a.tenant_id = $2`, + [id, tenantId] + ); + + if (!activity) { + throw new NotFoundError('Actividad no encontrada'); + } + + return activity; + } + + async create(dto: CreateActivityDto, tenantId: string, userId: string): Promise { + const activity = await queryOne( + `INSERT INTO system.activities ( + tenant_id, model, record_id, activity_type, summary, description, + assigned_to, assigned_by, due_date, due_time, created_by + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $8) + RETURNING *`, + [ + tenantId, dto.model, dto.record_id, dto.activity_type, + dto.summary, dto.description, dto.assigned_to || userId, + userId, dto.due_date, dto.due_time + ] + ); + + return activity!; + } + + async update(id: string, dto: UpdateActivityDto, tenantId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status === 'done' || existing.status === 'cancelled') { + throw new ValidationError('No se puede editar una actividad completada o cancelada'); + } + + const updateFields: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (dto.activity_type !== undefined) { + updateFields.push(`activity_type = $${paramIndex++}`); + values.push(dto.activity_type); + } + if (dto.summary !== undefined) { + updateFields.push(`summary = $${paramIndex++}`); + values.push(dto.summary); + } + if (dto.description !== undefined) { + updateFields.push(`description = $${paramIndex++}`); + values.push(dto.description); + } + if (dto.assigned_to !== undefined) { + updateFields.push(`assigned_to = $${paramIndex++}`); + values.push(dto.assigned_to); + } + if (dto.due_date !== undefined) { + updateFields.push(`due_date = $${paramIndex++}`); + values.push(dto.due_date); + } + if (dto.due_time !== undefined) { + updateFields.push(`due_time = $${paramIndex++}`); + values.push(dto.due_time); + } + + if (updateFields.length === 0) { + return existing; + } + + values.push(id, tenantId); + + await query( + `UPDATE system.activities SET ${updateFields.join(', ')} + WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`, + values + ); + + return this.findById(id, tenantId); + } + + async markDone(id: string, tenantId: string, userId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status !== 'planned' && existing.status !== 'overdue') { + throw new ValidationError('Solo se pueden completar actividades planificadas o vencidas'); + } + + const activity = await queryOne( + `UPDATE system.activities SET + status = 'done', + completed_at = CURRENT_TIMESTAMP, + completed_by = $1 + WHERE id = $2 AND tenant_id = $3 + RETURNING *`, + [userId, id, tenantId] + ); + + return activity!; + } + + async cancel(id: string, tenantId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status === 'done') { + throw new ValidationError('No se puede cancelar una actividad completada'); + } + + const activity = await queryOne( + `UPDATE system.activities SET status = 'cancelled' + WHERE id = $1 AND tenant_id = $2 + RETURNING *`, + [id, tenantId] + ); + + return activity!; + } + + async reschedule(id: string, dueDate: string, dueTime: string | null, tenantId: string): Promise { + const existing = await this.findById(id, tenantId); + + if (existing.status === 'done' || existing.status === 'cancelled') { + throw new ValidationError('No se puede reprogramar una actividad completada o cancelada'); + } + + const activity = await queryOne( + `UPDATE system.activities SET + due_date = $1, + due_time = $2, + status = 'planned' + WHERE id = $3 AND tenant_id = $4 + RETURNING *`, + [dueDate, dueTime, id, tenantId] + ); + + return activity!; + } + + async delete(id: string, tenantId: string): Promise { + await this.findById(id, tenantId); + + await query( + `DELETE FROM system.activities WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + } + + async markOverdueActivities(tenantId?: string): Promise { + let whereClause = `WHERE status = 'planned' AND due_date < CURRENT_DATE`; + const params: any[] = []; + + if (tenantId) { + whereClause += ' AND tenant_id = $1'; + params.push(tenantId); + } + + const result = await query( + `UPDATE system.activities SET status = 'overdue' ${whereClause}`, + params + ); + + return result.length; + } +} + +export const activitiesService = new ActivitiesService(); diff --git a/src/modules/system/index.ts b/src/modules/system/index.ts new file mode 100644 index 0000000..7a4c7a1 --- /dev/null +++ b/src/modules/system/index.ts @@ -0,0 +1,5 @@ +export * from './messages.service.js'; +export * from './notifications.service.js'; +export * from './activities.service.js'; +export * from './system.controller.js'; +export { default as systemRoutes } from './system.routes.js'; diff --git a/src/modules/system/messages.service.ts b/src/modules/system/messages.service.ts new file mode 100644 index 0000000..d0a64f3 --- /dev/null +++ b/src/modules/system/messages.service.ts @@ -0,0 +1,234 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError } from '../../shared/errors/index.js'; + +export interface Message { + id: string; + tenant_id: string; + model: string; + record_id: string; + message_type: 'comment' | 'note' | 'email' | 'notification' | 'system'; + subject?: string; + body: string; + author_id?: string; + author_name?: string; + author_email?: string; + parent_id?: string; + attachment_ids: string[]; + created_at: Date; +} + +export interface CreateMessageDto { + model: string; + record_id: string; + message_type?: 'comment' | 'note' | 'email' | 'notification' | 'system'; + subject?: string; + body: string; + parent_id?: string; +} + +export interface MessageFilters { + model?: string; + record_id?: string; + message_type?: string; + author_id?: string; + search?: string; + page?: number; + limit?: number; +} + +export interface Follower { + id: string; + model: string; + record_id: string; + partner_id?: string; + user_id?: string; + user_name?: string; + partner_name?: string; + email_notifications: boolean; + created_at: Date; +} + +export interface AddFollowerDto { + model: string; + record_id: string; + user_id?: string; + partner_id?: string; + email_notifications?: boolean; +} + +class MessagesService { + async findAll(tenantId: string, filters: MessageFilters = {}): Promise<{ data: Message[]; total: number }> { + const { model, record_id, message_type, author_id, search, page = 1, limit = 50 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE m.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (model) { + whereClause += ` AND m.model = $${paramIndex++}`; + params.push(model); + } + + if (record_id) { + whereClause += ` AND m.record_id = $${paramIndex++}`; + params.push(record_id); + } + + if (message_type) { + whereClause += ` AND m.message_type = $${paramIndex++}`; + params.push(message_type); + } + + if (author_id) { + whereClause += ` AND m.author_id = $${paramIndex++}`; + params.push(author_id); + } + + if (search) { + whereClause += ` AND (m.subject ILIKE $${paramIndex} OR m.body ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM system.messages m ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT m.*, + u.first_name || ' ' || u.last_name as author_name, + u.email as author_email + FROM system.messages m + LEFT JOIN auth.users u ON m.author_id = u.id + ${whereClause} + ORDER BY m.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findByRecord(model: string, recordId: string, tenantId: string): Promise { + const messages = await query( + `SELECT m.*, + u.first_name || ' ' || u.last_name as author_name, + u.email as author_email + FROM system.messages m + LEFT JOIN auth.users u ON m.author_id = u.id + WHERE m.model = $1 AND m.record_id = $2 AND m.tenant_id = $3 + ORDER BY m.created_at DESC`, + [model, recordId, tenantId] + ); + + return messages; + } + + async findById(id: string, tenantId: string): Promise { + const message = await queryOne( + `SELECT m.*, + u.first_name || ' ' || u.last_name as author_name, + u.email as author_email + FROM system.messages m + LEFT JOIN auth.users u ON m.author_id = u.id + WHERE m.id = $1 AND m.tenant_id = $2`, + [id, tenantId] + ); + + if (!message) { + throw new NotFoundError('Mensaje no encontrado'); + } + + return message; + } + + async create(dto: CreateMessageDto, tenantId: string, userId: string): Promise { + // Get user info for author fields + const user = await queryOne<{ first_name: string; last_name: string; email: string }>( + `SELECT first_name, last_name, email FROM auth.users WHERE id = $1`, + [userId] + ); + + const message = await queryOne( + `INSERT INTO system.messages ( + tenant_id, model, record_id, message_type, subject, body, + author_id, author_name, author_email, parent_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *`, + [ + tenantId, dto.model, dto.record_id, + dto.message_type || 'comment', dto.subject, dto.body, + userId, user ? `${user.first_name} ${user.last_name}` : null, + user?.email, dto.parent_id + ] + ); + + return message!; + } + + async delete(id: string, tenantId: string): Promise { + await this.findById(id, tenantId); + + await query( + `DELETE FROM system.messages WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + } + + // ========== FOLLOWERS ========== + async getFollowers(model: string, recordId: string): Promise { + const followers = await query( + `SELECT mf.*, + u.first_name || ' ' || u.last_name as user_name, + p.name as partner_name + FROM system.message_followers mf + LEFT JOIN auth.users u ON mf.user_id = u.id + LEFT JOIN core.partners p ON mf.partner_id = p.id + WHERE mf.model = $1 AND mf.record_id = $2 + ORDER BY mf.created_at DESC`, + [model, recordId] + ); + + return followers; + } + + async addFollower(dto: AddFollowerDto): Promise { + const follower = await queryOne( + `INSERT INTO system.message_followers ( + model, record_id, user_id, partner_id, email_notifications + ) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (model, record_id, COALESCE(user_id, partner_id)) DO UPDATE + SET email_notifications = EXCLUDED.email_notifications + RETURNING *`, + [dto.model, dto.record_id, dto.user_id, dto.partner_id, dto.email_notifications ?? true] + ); + + return follower!; + } + + async removeFollower(model: string, recordId: string, userId?: string, partnerId?: string): Promise { + if (userId) { + await query( + `DELETE FROM system.message_followers + WHERE model = $1 AND record_id = $2 AND user_id = $3`, + [model, recordId, userId] + ); + } else if (partnerId) { + await query( + `DELETE FROM system.message_followers + WHERE model = $1 AND record_id = $2 AND partner_id = $3`, + [model, recordId, partnerId] + ); + } + } +} + +export const messagesService = new MessagesService(); diff --git a/src/modules/system/notifications.service.ts b/src/modules/system/notifications.service.ts new file mode 100644 index 0000000..1b023e8 --- /dev/null +++ b/src/modules/system/notifications.service.ts @@ -0,0 +1,227 @@ +import { query, queryOne } from '../../config/database.js'; +import { NotFoundError } from '../../shared/errors/index.js'; + +export interface Notification { + id: string; + tenant_id: string; + user_id: string; + title: string; + message: string; + url?: string; + model?: string; + record_id?: string; + status: 'pending' | 'sent' | 'read' | 'failed'; + read_at?: Date; + created_at: Date; + sent_at?: Date; +} + +export interface CreateNotificationDto { + user_id: string; + title: string; + message: string; + url?: string; + model?: string; + record_id?: string; +} + +export interface NotificationFilters { + user_id?: string; + status?: string; + unread_only?: boolean; + model?: string; + search?: string; + page?: number; + limit?: number; +} + +class NotificationsService { + async findAll(tenantId: string, filters: NotificationFilters = {}): Promise<{ data: Notification[]; total: number }> { + const { user_id, status, unread_only, model, search, page = 1, limit = 50 } = filters; + const offset = (page - 1) * limit; + + let whereClause = 'WHERE n.tenant_id = $1'; + const params: any[] = [tenantId]; + let paramIndex = 2; + + if (user_id) { + whereClause += ` AND n.user_id = $${paramIndex++}`; + params.push(user_id); + } + + if (status) { + whereClause += ` AND n.status = $${paramIndex++}`; + params.push(status); + } + + if (unread_only) { + whereClause += ` AND n.read_at IS NULL`; + } + + if (model) { + whereClause += ` AND n.model = $${paramIndex++}`; + params.push(model); + } + + if (search) { + whereClause += ` AND (n.title ILIKE $${paramIndex} OR n.message ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const countResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM system.notifications n ${whereClause}`, + params + ); + + params.push(limit, offset); + const data = await query( + `SELECT n.* + FROM system.notifications n + ${whereClause} + ORDER BY n.created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + params + ); + + return { + data, + total: parseInt(countResult?.count || '0', 10), + }; + } + + async findByUser(userId: string, tenantId: string, unreadOnly: boolean = false): Promise { + let whereClause = 'WHERE n.user_id = $1 AND n.tenant_id = $2'; + if (unreadOnly) { + whereClause += ' AND n.read_at IS NULL'; + } + + const notifications = await query( + `SELECT n.* + FROM system.notifications n + ${whereClause} + ORDER BY n.created_at DESC + LIMIT 100`, + [userId, tenantId] + ); + + return notifications; + } + + async getUnreadCount(userId: string, tenantId: string): Promise { + const result = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count + FROM system.notifications + WHERE user_id = $1 AND tenant_id = $2 AND read_at IS NULL`, + [userId, tenantId] + ); + + return parseInt(result?.count || '0', 10); + } + + async findById(id: string, tenantId: string): Promise { + const notification = await queryOne( + `SELECT n.* + FROM system.notifications n + WHERE n.id = $1 AND n.tenant_id = $2`, + [id, tenantId] + ); + + if (!notification) { + throw new NotFoundError('Notificación no encontrada'); + } + + return notification; + } + + async create(dto: CreateNotificationDto, tenantId: string): Promise { + const notification = await queryOne( + `INSERT INTO system.notifications ( + tenant_id, user_id, title, message, url, model, record_id, status, sent_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'sent', CURRENT_TIMESTAMP) + RETURNING *`, + [tenantId, dto.user_id, dto.title, dto.message, dto.url, dto.model, dto.record_id] + ); + + return notification!; + } + + async createBulk(notifications: CreateNotificationDto[], tenantId: string): Promise { + if (notifications.length === 0) return 0; + + const values = notifications.map((n, i) => { + const base = i * 7; + return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, 'sent', CURRENT_TIMESTAMP)`; + }).join(', '); + + const params = notifications.flatMap(n => [ + tenantId, n.user_id, n.title, n.message, n.url, n.model, n.record_id + ]); + + const result = await query( + `INSERT INTO system.notifications ( + tenant_id, user_id, title, message, url, model, record_id, status, sent_at + ) + VALUES ${values}`, + params + ); + + return notifications.length; + } + + async markAsRead(id: string, tenantId: string): Promise { + await this.findById(id, tenantId); + + const notification = await queryOne( + `UPDATE system.notifications SET + status = 'read', + read_at = CURRENT_TIMESTAMP + WHERE id = $1 AND tenant_id = $2 + RETURNING *`, + [id, tenantId] + ); + + return notification!; + } + + async markAllAsRead(userId: string, tenantId: string): Promise { + const result = await query( + `UPDATE system.notifications SET + status = 'read', + read_at = CURRENT_TIMESTAMP + WHERE user_id = $1 AND tenant_id = $2 AND read_at IS NULL`, + [userId, tenantId] + ); + + return result.length; + } + + async delete(id: string, tenantId: string): Promise { + await this.findById(id, tenantId); + + await query( + `DELETE FROM system.notifications WHERE id = $1 AND tenant_id = $2`, + [id, tenantId] + ); + } + + async deleteOld(daysToKeep: number = 30, tenantId?: string): Promise { + let whereClause = `WHERE read_at IS NOT NULL AND created_at < CURRENT_TIMESTAMP - INTERVAL '${daysToKeep} days'`; + const params: any[] = []; + + if (tenantId) { + whereClause += ' AND tenant_id = $1'; + params.push(tenantId); + } + + const result = await query( + `DELETE FROM system.notifications ${whereClause}`, + params + ); + + return result.length; + } +} + +export const notificationsService = new NotificationsService(); diff --git a/src/modules/system/system.controller.ts b/src/modules/system/system.controller.ts new file mode 100644 index 0000000..5ee4413 --- /dev/null +++ b/src/modules/system/system.controller.ts @@ -0,0 +1,404 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { messagesService, CreateMessageDto, MessageFilters, AddFollowerDto } from './messages.service.js'; +import { notificationsService, CreateNotificationDto, NotificationFilters } from './notifications.service.js'; +import { activitiesService, CreateActivityDto, UpdateActivityDto, ActivityFilters } from './activities.service.js'; +import { AuthenticatedRequest } from '../../shared/middleware/auth.middleware.js'; +import { ValidationError } from '../../shared/errors/index.js'; + +// ========== MESSAGE SCHEMAS ========== +const createMessageSchema = z.object({ + model: z.string().min(1).max(100), + record_id: z.string().uuid(), + message_type: z.enum(['comment', 'note', 'email', 'notification', 'system']).default('comment'), + subject: z.string().max(255).optional(), + body: z.string().min(1), + parent_id: z.string().uuid().optional(), +}); + +const messageQuerySchema = z.object({ + model: z.string().optional(), + record_id: z.string().uuid().optional(), + message_type: z.enum(['comment', 'note', 'email', 'notification', 'system']).optional(), + author_id: z.string().uuid().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(50), +}); + +const addFollowerSchema = z.object({ + model: z.string().min(1).max(100), + record_id: z.string().uuid(), + user_id: z.string().uuid().optional(), + partner_id: z.string().uuid().optional(), + email_notifications: z.boolean().default(true), +}).refine(data => data.user_id || data.partner_id, { + message: 'Debe especificar user_id o partner_id', +}); + +// ========== NOTIFICATION SCHEMAS ========== +const createNotificationSchema = z.object({ + user_id: z.string().uuid(), + title: z.string().min(1).max(255), + message: z.string().min(1), + url: z.string().max(500).optional(), + model: z.string().max(100).optional(), + record_id: z.string().uuid().optional(), +}); + +const notificationQuerySchema = z.object({ + user_id: z.string().uuid().optional(), + status: z.enum(['pending', 'sent', 'read', 'failed']).optional(), + unread_only: z.coerce.boolean().optional(), + model: z.string().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(50), +}); + +// ========== ACTIVITY SCHEMAS ========== +const createActivitySchema = z.object({ + model: z.string().min(1).max(100), + record_id: z.string().uuid(), + activity_type: z.enum(['call', 'meeting', 'email', 'todo', 'follow_up', 'custom']), + summary: z.string().min(1).max(255), + description: z.string().optional(), + assigned_to: z.string().uuid().optional(), + due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + due_time: z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/).optional(), +}); + +const updateActivitySchema = z.object({ + activity_type: z.enum(['call', 'meeting', 'email', 'todo', 'follow_up', 'custom']).optional(), + summary: z.string().min(1).max(255).optional(), + description: z.string().optional().nullable(), + assigned_to: z.string().uuid().optional().nullable(), + due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + due_time: z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/).optional().nullable(), +}); + +const rescheduleActivitySchema = z.object({ + due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + due_time: z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/).optional().nullable(), +}); + +const activityQuerySchema = z.object({ + model: z.string().optional(), + record_id: z.string().uuid().optional(), + activity_type: z.enum(['call', 'meeting', 'email', 'todo', 'follow_up', 'custom']).optional(), + assigned_to: z.string().uuid().optional(), + status: z.enum(['planned', 'done', 'cancelled', 'overdue']).optional(), + due_from: z.string().optional(), + due_to: z.string().optional(), + overdue_only: z.coerce.boolean().optional(), + search: z.string().optional(), + page: z.coerce.number().int().positive().default(1), + limit: z.coerce.number().int().positive().max(100).default(50), +}); + +class SystemController { + // ========== MESSAGES ========== + async getMessages(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = messageQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + const filters: MessageFilters = queryResult.data; + const result = await messagesService.findAll(req.tenantId!, filters); + res.json({ + success: true, + data: result.data, + meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 50)) }, + }); + } catch (error) { + next(error); + } + } + + async getMessagesByRecord(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { model, recordId } = req.params; + const messages = await messagesService.findByRecord(model, recordId, req.tenantId!); + res.json({ success: true, data: messages }); + } catch (error) { + next(error); + } + } + + async getMessage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const message = await messagesService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: message }); + } catch (error) { + next(error); + } + } + + async createMessage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createMessageSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de mensaje inválidos', parseResult.error.errors); + } + const dto: CreateMessageDto = parseResult.data; + const message = await messagesService.create(dto, req.tenantId!, req.user!.userId); + res.status(201).json({ success: true, data: message, message: 'Mensaje creado exitosamente' }); + } catch (error) { + next(error); + } + } + + async deleteMessage(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await messagesService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Mensaje eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== FOLLOWERS ========== + async getFollowers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { model, recordId } = req.params; + const followers = await messagesService.getFollowers(model, recordId); + res.json({ success: true, data: followers }); + } catch (error) { + next(error); + } + } + + async addFollower(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = addFollowerSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de seguidor inválidos', parseResult.error.errors); + } + const dto: AddFollowerDto = parseResult.data; + const follower = await messagesService.addFollower(dto); + res.status(201).json({ success: true, data: follower, message: 'Seguidor agregado exitosamente' }); + } catch (error) { + next(error); + } + } + + async removeFollower(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { model, recordId } = req.params; + const { user_id, partner_id } = req.query; + await messagesService.removeFollower(model, recordId, user_id as string, partner_id as string); + res.json({ success: true, message: 'Seguidor eliminado exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== NOTIFICATIONS ========== + async getNotifications(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = notificationQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + const filters: NotificationFilters = queryResult.data; + const result = await notificationsService.findAll(req.tenantId!, filters); + res.json({ + success: true, + data: result.data, + meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 50)) }, + }); + } catch (error) { + next(error); + } + } + + async getMyNotifications(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const unreadOnly = req.query.unread_only === 'true'; + const notifications = await notificationsService.findByUser(req.user!.userId, req.tenantId!, unreadOnly); + const unreadCount = await notificationsService.getUnreadCount(req.user!.userId, req.tenantId!); + res.json({ success: true, data: notifications, meta: { unread_count: unreadCount } }); + } catch (error) { + next(error); + } + } + + async getUnreadCount(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const count = await notificationsService.getUnreadCount(req.user!.userId, req.tenantId!); + res.json({ success: true, data: { count } }); + } catch (error) { + next(error); + } + } + + async getNotification(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const notification = await notificationsService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: notification }); + } catch (error) { + next(error); + } + } + + async createNotification(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createNotificationSchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de notificación inválidos', parseResult.error.errors); + } + const dto: CreateNotificationDto = parseResult.data; + const notification = await notificationsService.create(dto, req.tenantId!); + res.status(201).json({ success: true, data: notification, message: 'Notificación creada exitosamente' }); + } catch (error) { + next(error); + } + } + + async markNotificationAsRead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const notification = await notificationsService.markAsRead(req.params.id, req.tenantId!); + res.json({ success: true, data: notification, message: 'Notificación marcada como leída' }); + } catch (error) { + next(error); + } + } + + async markAllNotificationsAsRead(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const count = await notificationsService.markAllAsRead(req.user!.userId, req.tenantId!); + res.json({ success: true, message: `${count} notificaciones marcadas como leídas` }); + } catch (error) { + next(error); + } + } + + async deleteNotification(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await notificationsService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Notificación eliminada exitosamente' }); + } catch (error) { + next(error); + } + } + + // ========== ACTIVITIES ========== + async getActivities(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const queryResult = activityQuerySchema.safeParse(req.query); + if (!queryResult.success) { + throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.errors); + } + const filters: ActivityFilters = queryResult.data; + const result = await activitiesService.findAll(req.tenantId!, filters); + res.json({ + success: true, + data: result.data, + meta: { total: result.total, page: filters.page, limit: filters.limit, totalPages: Math.ceil(result.total / (filters.limit || 50)) }, + }); + } catch (error) { + next(error); + } + } + + async getActivitiesByRecord(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const { model, recordId } = req.params; + const activities = await activitiesService.findByRecord(model, recordId, req.tenantId!); + res.json({ success: true, data: activities }); + } catch (error) { + next(error); + } + } + + async getMyActivities(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const status = req.query.status as string | undefined; + const activities = await activitiesService.findByUser(req.user!.userId, req.tenantId!, status); + res.json({ success: true, data: activities }); + } catch (error) { + next(error); + } + } + + async getActivity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const activity = await activitiesService.findById(req.params.id, req.tenantId!); + res.json({ success: true, data: activity }); + } catch (error) { + next(error); + } + } + + async createActivity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = createActivitySchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de actividad inválidos', parseResult.error.errors); + } + const dto: CreateActivityDto = parseResult.data; + const activity = await activitiesService.create(dto, req.tenantId!, req.user!.userId); + res.status(201).json({ success: true, data: activity, message: 'Actividad creada exitosamente' }); + } catch (error) { + next(error); + } + } + + async updateActivity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = updateActivitySchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de actividad inválidos', parseResult.error.errors); + } + const dto: UpdateActivityDto = parseResult.data; + const activity = await activitiesService.update(req.params.id, dto, req.tenantId!); + res.json({ success: true, data: activity, message: 'Actividad actualizada exitosamente' }); + } catch (error) { + next(error); + } + } + + async markActivityDone(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const activity = await activitiesService.markDone(req.params.id, req.tenantId!, req.user!.userId); + res.json({ success: true, data: activity, message: 'Actividad completada exitosamente' }); + } catch (error) { + next(error); + } + } + + async cancelActivity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const activity = await activitiesService.cancel(req.params.id, req.tenantId!); + res.json({ success: true, data: activity, message: 'Actividad cancelada exitosamente' }); + } catch (error) { + next(error); + } + } + + async rescheduleActivity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const parseResult = rescheduleActivitySchema.safeParse(req.body); + if (!parseResult.success) { + throw new ValidationError('Datos de reprogramación inválidos', parseResult.error.errors); + } + const { due_date, due_time } = parseResult.data; + const activity = await activitiesService.reschedule(req.params.id, due_date, due_time ?? null, req.tenantId!); + res.json({ success: true, data: activity, message: 'Actividad reprogramada exitosamente' }); + } catch (error) { + next(error); + } + } + + async deleteActivity(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + await activitiesService.delete(req.params.id, req.tenantId!); + res.json({ success: true, message: 'Actividad eliminada exitosamente' }); + } catch (error) { + next(error); + } + } +} + +export const systemController = new SystemController(); diff --git a/src/modules/system/system.routes.ts b/src/modules/system/system.routes.ts new file mode 100644 index 0000000..6cd819c --- /dev/null +++ b/src/modules/system/system.routes.ts @@ -0,0 +1,48 @@ +import { Router } from 'express'; +import { systemController } from './system.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// ========== MESSAGES (Chatter) ========== +router.get('/messages', (req, res, next) => systemController.getMessages(req, res, next)); +router.get('/messages/record/:model/:recordId', (req, res, next) => systemController.getMessagesByRecord(req, res, next)); +router.get('/messages/:id', (req, res, next) => systemController.getMessage(req, res, next)); +router.post('/messages', (req, res, next) => systemController.createMessage(req, res, next)); +router.delete('/messages/:id', (req, res, next) => systemController.deleteMessage(req, res, next)); + +// ========== FOLLOWERS ========== +router.get('/followers/:model/:recordId', (req, res, next) => systemController.getFollowers(req, res, next)); +router.post('/followers', (req, res, next) => systemController.addFollower(req, res, next)); +router.delete('/followers/:model/:recordId', (req, res, next) => systemController.removeFollower(req, res, next)); + +// ========== NOTIFICATIONS ========== +router.get('/notifications', requireRoles('admin', 'super_admin'), (req, res, next) => + systemController.getNotifications(req, res, next) +); +router.get('/notifications/me', (req, res, next) => systemController.getMyNotifications(req, res, next)); +router.get('/notifications/me/count', (req, res, next) => systemController.getUnreadCount(req, res, next)); +router.get('/notifications/:id', (req, res, next) => systemController.getNotification(req, res, next)); +router.post('/notifications', requireRoles('admin', 'super_admin'), (req, res, next) => + systemController.createNotification(req, res, next) +); +router.post('/notifications/:id/read', (req, res, next) => systemController.markNotificationAsRead(req, res, next)); +router.post('/notifications/read-all', (req, res, next) => systemController.markAllNotificationsAsRead(req, res, next)); +router.delete('/notifications/:id', (req, res, next) => systemController.deleteNotification(req, res, next)); + +// ========== ACTIVITIES ========== +router.get('/activities', (req, res, next) => systemController.getActivities(req, res, next)); +router.get('/activities/record/:model/:recordId', (req, res, next) => systemController.getActivitiesByRecord(req, res, next)); +router.get('/activities/me', (req, res, next) => systemController.getMyActivities(req, res, next)); +router.get('/activities/:id', (req, res, next) => systemController.getActivity(req, res, next)); +router.post('/activities', (req, res, next) => systemController.createActivity(req, res, next)); +router.put('/activities/:id', (req, res, next) => systemController.updateActivity(req, res, next)); +router.post('/activities/:id/done', (req, res, next) => systemController.markActivityDone(req, res, next)); +router.post('/activities/:id/cancel', (req, res, next) => systemController.cancelActivity(req, res, next)); +router.post('/activities/:id/reschedule', (req, res, next) => systemController.rescheduleActivity(req, res, next)); +router.delete('/activities/:id', (req, res, next) => systemController.deleteActivity(req, res, next)); + +export default router; diff --git a/src/modules/tenants/index.ts b/src/modules/tenants/index.ts new file mode 100644 index 0000000..de1b03d --- /dev/null +++ b/src/modules/tenants/index.ts @@ -0,0 +1,7 @@ +// Tenants module exports +export { tenantsService } from './tenants.service.js'; +export { tenantsController } from './tenants.controller.js'; +export { default as tenantsRoutes } from './tenants.routes.js'; + +// Types +export type { CreateTenantDto, UpdateTenantDto, TenantStats, TenantWithStats } from './tenants.service.js'; diff --git a/src/modules/tenants/tenants.controller.ts b/src/modules/tenants/tenants.controller.ts new file mode 100644 index 0000000..6f02fb0 --- /dev/null +++ b/src/modules/tenants/tenants.controller.ts @@ -0,0 +1,315 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { tenantsService } from './tenants.service.js'; +import { TenantStatus } from '../auth/entities/index.js'; +import { ApiResponse, AuthenticatedRequest, ValidationError, PaginationParams } from '../../shared/types/index.js'; + +// Validation schemas +const createTenantSchema = z.object({ + name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'), + subdomain: z.string() + .min(3, 'El subdominio debe tener al menos 3 caracteres') + .max(50, 'El subdominio no puede exceder 50 caracteres') + .regex(/^[a-z0-9-]+$/, 'El subdominio solo puede contener letras minúsculas, números y guiones'), + plan: z.enum(['basic', 'standard', 'premium', 'enterprise']).optional(), + maxUsers: z.number().int().min(1).max(1000).optional(), + settings: z.record(z.any()).optional(), +}); + +const updateTenantSchema = z.object({ + name: z.string().min(2).optional(), + plan: z.enum(['basic', 'standard', 'premium', 'enterprise']).optional(), + maxUsers: z.number().int().min(1).max(1000).optional(), + settings: z.record(z.any()).optional(), +}); + +const updateSettingsSchema = z.object({ + settings: z.record(z.any()), +}); + +export class TenantsController { + /** + * GET /tenants - List all tenants (super_admin only) + */ + async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const sortBy = req.query.sortBy as string || 'name'; + const sortOrder = (req.query.sortOrder as 'asc' | 'desc') || 'asc'; + + const params: PaginationParams = { page, limit, sortBy, sortOrder }; + + // Build filter + const filter: { status?: TenantStatus; search?: string } = {}; + if (req.query.status) { + filter.status = req.query.status as TenantStatus; + } + if (req.query.search) { + filter.search = req.query.search as string; + } + + const result = await tenantsService.findAll(params, filter); + + const response: ApiResponse = { + success: true, + data: result.tenants, + meta: { + page, + limit, + total: result.total, + totalPages: Math.ceil(result.total / limit), + }, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /tenants/current - Get current user's tenant + */ + async getCurrent(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const tenant = await tenantsService.findById(tenantId); + + const response: ApiResponse = { + success: true, + data: tenant, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /tenants/:id - Get tenant by ID (super_admin only) + */ + async findById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.params.id; + const tenant = await tenantsService.findById(tenantId); + + const response: ApiResponse = { + success: true, + data: tenant, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /tenants/:id/stats - Get tenant statistics + */ + async getStats(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.params.id; + const stats = await tenantsService.getTenantStats(tenantId); + + const response: ApiResponse = { + success: true, + data: stats, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * POST /tenants - Create new tenant (super_admin only) + */ + async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = createTenantSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const createdBy = req.user!.userId; + const tenant = await tenantsService.create(validation.data, createdBy); + + const response: ApiResponse = { + success: true, + data: tenant, + message: 'Tenant creado exitosamente', + }; + + res.status(201).json(response); + } catch (error) { + next(error); + } + } + + /** + * PUT /tenants/:id - Update tenant + */ + async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = updateTenantSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const tenantId = req.params.id; + const updatedBy = req.user!.userId; + + const tenant = await tenantsService.update(tenantId, validation.data, updatedBy); + + const response: ApiResponse = { + success: true, + data: tenant, + message: 'Tenant actualizado exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * POST /tenants/:id/suspend - Suspend tenant (super_admin only) + */ + async suspend(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.params.id; + const updatedBy = req.user!.userId; + + const tenant = await tenantsService.suspend(tenantId, updatedBy); + + const response: ApiResponse = { + success: true, + data: tenant, + message: 'Tenant suspendido exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * POST /tenants/:id/activate - Activate tenant (super_admin only) + */ + async activate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.params.id; + const updatedBy = req.user!.userId; + + const tenant = await tenantsService.activate(tenantId, updatedBy); + + const response: ApiResponse = { + success: true, + data: tenant, + message: 'Tenant activado exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * DELETE /tenants/:id - Soft delete tenant (super_admin only) + */ + async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.params.id; + const deletedBy = req.user!.userId; + + await tenantsService.delete(tenantId, deletedBy); + + const response: ApiResponse = { + success: true, + message: 'Tenant eliminado exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /tenants/:id/settings - Get tenant settings + */ + async getSettings(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.params.id; + const settings = await tenantsService.getSettings(tenantId); + + const response: ApiResponse = { + success: true, + data: settings, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * PUT /tenants/:id/settings - Update tenant settings + */ + async updateSettings(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = updateSettingsSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const tenantId = req.params.id; + const updatedBy = req.user!.userId; + + const settings = await tenantsService.updateSettings( + tenantId, + validation.data.settings, + updatedBy + ); + + const response: ApiResponse = { + success: true, + data: settings, + message: 'Configuración actualizada exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + /** + * GET /tenants/:id/can-add-user - Check if tenant can add more users + */ + async canAddUser(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.params.id; + const result = await tenantsService.canAddUser(tenantId); + + const response: ApiResponse = { + success: true, + data: result, + }; + + res.json(response); + } catch (error) { + next(error); + } + } +} + +export const tenantsController = new TenantsController(); diff --git a/src/modules/tenants/tenants.routes.ts b/src/modules/tenants/tenants.routes.ts new file mode 100644 index 0000000..c47acf0 --- /dev/null +++ b/src/modules/tenants/tenants.routes.ts @@ -0,0 +1,69 @@ +import { Router } from 'express'; +import { tenantsController } from './tenants.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// Get current user's tenant (any authenticated user) +router.get('/current', (req, res, next) => + tenantsController.getCurrent(req, res, next) +); + +// List all tenants (super_admin only) +router.get('/', requireRoles('super_admin'), (req, res, next) => + tenantsController.findAll(req, res, next) +); + +// Get tenant by ID (super_admin only) +router.get('/:id', requireRoles('super_admin'), (req, res, next) => + tenantsController.findById(req, res, next) +); + +// Get tenant statistics (super_admin only) +router.get('/:id/stats', requireRoles('super_admin'), (req, res, next) => + tenantsController.getStats(req, res, next) +); + +// Create tenant (super_admin only) +router.post('/', requireRoles('super_admin'), (req, res, next) => + tenantsController.create(req, res, next) +); + +// Update tenant (super_admin only) +router.put('/:id', requireRoles('super_admin'), (req, res, next) => + tenantsController.update(req, res, next) +); + +// Suspend tenant (super_admin only) +router.post('/:id/suspend', requireRoles('super_admin'), (req, res, next) => + tenantsController.suspend(req, res, next) +); + +// Activate tenant (super_admin only) +router.post('/:id/activate', requireRoles('super_admin'), (req, res, next) => + tenantsController.activate(req, res, next) +); + +// Delete tenant (super_admin only) +router.delete('/:id', requireRoles('super_admin'), (req, res, next) => + tenantsController.delete(req, res, next) +); + +// Tenant settings (admin and super_admin) +router.get('/:id/settings', requireRoles('admin', 'super_admin'), (req, res, next) => + tenantsController.getSettings(req, res, next) +); + +router.put('/:id/settings', requireRoles('admin', 'super_admin'), (req, res, next) => + tenantsController.updateSettings(req, res, next) +); + +// Check user limit (admin and super_admin) +router.get('/:id/can-add-user', requireRoles('admin', 'super_admin'), (req, res, next) => + tenantsController.canAddUser(req, res, next) +); + +export default router; diff --git a/src/modules/tenants/tenants.service.ts b/src/modules/tenants/tenants.service.ts new file mode 100644 index 0000000..ca2bbfa --- /dev/null +++ b/src/modules/tenants/tenants.service.ts @@ -0,0 +1,449 @@ +import { Repository } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { Tenant, TenantStatus, User, UserStatus, Company, Role } from '../auth/entities/index.js'; +import { PaginationParams, NotFoundError, ValidationError, ForbiddenError } from '../../shared/types/index.js'; +import { logger } from '../../shared/utils/logger.js'; + +// ===== Interfaces ===== + +export interface CreateTenantDto { + name: string; + subdomain: string; + plan?: string; + maxUsers?: number; + settings?: Record; +} + +export interface UpdateTenantDto { + name?: string; + plan?: string; + maxUsers?: number; + settings?: Record; +} + +export interface TenantStats { + usersCount: number; + companiesCount: number; + rolesCount: number; + activeUsersCount: number; +} + +export interface TenantWithStats extends Tenant { + stats?: TenantStats; +} + +// ===== TenantsService Class ===== + +class TenantsService { + private tenantRepository: Repository; + private userRepository: Repository; + private companyRepository: Repository; + private roleRepository: Repository; + + constructor() { + this.tenantRepository = AppDataSource.getRepository(Tenant); + this.userRepository = AppDataSource.getRepository(User); + this.companyRepository = AppDataSource.getRepository(Company); + this.roleRepository = AppDataSource.getRepository(Role); + } + + /** + * Get all tenants with pagination (super_admin only) + */ + async findAll( + params: PaginationParams, + filter?: { status?: TenantStatus; search?: string } + ): Promise<{ tenants: Tenant[]; total: number }> { + try { + const { page, limit, sortBy = 'name', sortOrder = 'asc' } = params; + const skip = (page - 1) * limit; + + const queryBuilder = this.tenantRepository + .createQueryBuilder('tenant') + .where('tenant.deletedAt IS NULL') + .orderBy(`tenant.${sortBy}`, sortOrder.toUpperCase() as 'ASC' | 'DESC') + .skip(skip) + .take(limit); + + // Apply filters + if (filter?.status) { + queryBuilder.andWhere('tenant.status = :status', { status: filter.status }); + } + if (filter?.search) { + queryBuilder.andWhere( + '(tenant.name ILIKE :search OR tenant.subdomain ILIKE :search)', + { search: `%${filter.search}%` } + ); + } + + const [tenants, total] = await queryBuilder.getManyAndCount(); + + logger.debug('Tenants retrieved', { count: tenants.length, total }); + + return { tenants, total }; + } catch (error) { + logger.error('Error retrieving tenants', { + error: (error as Error).message, + }); + throw error; + } + } + + /** + * Get tenant by ID + */ + async findById(tenantId: string): Promise { + try { + const tenant = await this.tenantRepository.findOne({ + where: { id: tenantId, deletedAt: undefined }, + }); + + if (!tenant) { + throw new NotFoundError('Tenant no encontrado'); + } + + // Get stats + const stats = await this.getTenantStats(tenantId); + + return { ...tenant, stats }; + } catch (error) { + logger.error('Error finding tenant', { + error: (error as Error).message, + tenantId, + }); + throw error; + } + } + + /** + * Get tenant by subdomain + */ + async findBySubdomain(subdomain: string): Promise { + try { + return await this.tenantRepository.findOne({ + where: { subdomain, deletedAt: undefined }, + }); + } catch (error) { + logger.error('Error finding tenant by subdomain', { + error: (error as Error).message, + subdomain, + }); + throw error; + } + } + + /** + * Get tenant statistics + */ + async getTenantStats(tenantId: string): Promise { + try { + const [usersCount, activeUsersCount, companiesCount, rolesCount] = await Promise.all([ + this.userRepository.count({ + where: { tenantId, deletedAt: undefined }, + }), + this.userRepository.count({ + where: { tenantId, status: UserStatus.ACTIVE, deletedAt: undefined }, + }), + this.companyRepository.count({ + where: { tenantId, deletedAt: undefined }, + }), + this.roleRepository.count({ + where: { tenantId, deletedAt: undefined }, + }), + ]); + + return { + usersCount, + activeUsersCount, + companiesCount, + rolesCount, + }; + } catch (error) { + logger.error('Error getting tenant stats', { + error: (error as Error).message, + tenantId, + }); + throw error; + } + } + + /** + * Create a new tenant (super_admin only) + */ + async create(data: CreateTenantDto, createdBy: string): Promise { + try { + // Validate subdomain uniqueness + const existing = await this.findBySubdomain(data.subdomain); + if (existing) { + throw new ValidationError('Ya existe un tenant con este subdominio'); + } + + // Validate subdomain format (alphanumeric and hyphens only) + if (!/^[a-z0-9-]+$/.test(data.subdomain)) { + throw new ValidationError('El subdominio solo puede contener letras minúsculas, números y guiones'); + } + + // Generate schema name from subdomain + const schemaName = `tenant_${data.subdomain.replace(/-/g, '_')}`; + + // Create tenant + const tenant = this.tenantRepository.create({ + name: data.name, + subdomain: data.subdomain, + schemaName, + status: TenantStatus.ACTIVE, + plan: data.plan || 'basic', + maxUsers: data.maxUsers || 10, + settings: data.settings || {}, + createdBy, + }); + + await this.tenantRepository.save(tenant); + + logger.info('Tenant created', { + tenantId: tenant.id, + subdomain: tenant.subdomain, + createdBy, + }); + + return tenant; + } catch (error) { + logger.error('Error creating tenant', { + error: (error as Error).message, + data, + }); + throw error; + } + } + + /** + * Update a tenant + */ + async update( + tenantId: string, + data: UpdateTenantDto, + updatedBy: string + ): Promise { + try { + const tenant = await this.tenantRepository.findOne({ + where: { id: tenantId, deletedAt: undefined }, + }); + + if (!tenant) { + throw new NotFoundError('Tenant no encontrado'); + } + + // Update allowed fields + if (data.name !== undefined) tenant.name = data.name; + if (data.plan !== undefined) tenant.plan = data.plan; + if (data.maxUsers !== undefined) tenant.maxUsers = data.maxUsers; + if (data.settings !== undefined) { + tenant.settings = { ...tenant.settings, ...data.settings }; + } + + tenant.updatedBy = updatedBy; + tenant.updatedAt = new Date(); + + await this.tenantRepository.save(tenant); + + logger.info('Tenant updated', { + tenantId, + updatedBy, + }); + + return await this.findById(tenantId); + } catch (error) { + logger.error('Error updating tenant', { + error: (error as Error).message, + tenantId, + }); + throw error; + } + } + + /** + * Change tenant status + */ + async changeStatus( + tenantId: string, + status: TenantStatus, + updatedBy: string + ): Promise { + try { + const tenant = await this.tenantRepository.findOne({ + where: { id: tenantId, deletedAt: undefined }, + }); + + if (!tenant) { + throw new NotFoundError('Tenant no encontrado'); + } + + tenant.status = status; + tenant.updatedBy = updatedBy; + tenant.updatedAt = new Date(); + + await this.tenantRepository.save(tenant); + + logger.info('Tenant status changed', { + tenantId, + status, + updatedBy, + }); + + return tenant; + } catch (error) { + logger.error('Error changing tenant status', { + error: (error as Error).message, + tenantId, + status, + }); + throw error; + } + } + + /** + * Suspend a tenant + */ + async suspend(tenantId: string, updatedBy: string): Promise { + return this.changeStatus(tenantId, TenantStatus.SUSPENDED, updatedBy); + } + + /** + * Activate a tenant + */ + async activate(tenantId: string, updatedBy: string): Promise { + return this.changeStatus(tenantId, TenantStatus.ACTIVE, updatedBy); + } + + /** + * Soft delete a tenant + */ + async delete(tenantId: string, deletedBy: string): Promise { + try { + const tenant = await this.tenantRepository.findOne({ + where: { id: tenantId, deletedAt: undefined }, + }); + + if (!tenant) { + throw new NotFoundError('Tenant no encontrado'); + } + + // Check if tenant has active users + const activeUsers = await this.userRepository.count({ + where: { tenantId, status: UserStatus.ACTIVE, deletedAt: undefined }, + }); + + if (activeUsers > 0) { + throw new ForbiddenError( + `No se puede eliminar el tenant porque tiene ${activeUsers} usuario(s) activo(s). Primero desactive todos los usuarios.` + ); + } + + // Soft delete + tenant.deletedAt = new Date(); + tenant.deletedBy = deletedBy; + tenant.status = TenantStatus.CANCELLED; + + await this.tenantRepository.save(tenant); + + logger.info('Tenant deleted', { + tenantId, + deletedBy, + }); + } catch (error) { + logger.error('Error deleting tenant', { + error: (error as Error).message, + tenantId, + }); + throw error; + } + } + + /** + * Get tenant settings + */ + async getSettings(tenantId: string): Promise> { + const tenant = await this.findById(tenantId); + return tenant.settings || {}; + } + + /** + * Update tenant settings (merge) + */ + async updateSettings( + tenantId: string, + settings: Record, + updatedBy: string + ): Promise> { + try { + const tenant = await this.tenantRepository.findOne({ + where: { id: tenantId, deletedAt: undefined }, + }); + + if (!tenant) { + throw new NotFoundError('Tenant no encontrado'); + } + + tenant.settings = { ...tenant.settings, ...settings }; + tenant.updatedBy = updatedBy; + tenant.updatedAt = new Date(); + + await this.tenantRepository.save(tenant); + + logger.info('Tenant settings updated', { + tenantId, + updatedBy, + }); + + return tenant.settings; + } catch (error) { + logger.error('Error updating tenant settings', { + error: (error as Error).message, + tenantId, + }); + throw error; + } + } + + /** + * Check if tenant has reached user limit + */ + async canAddUser(tenantId: string): Promise<{ allowed: boolean; reason?: string }> { + try { + const tenant = await this.tenantRepository.findOne({ + where: { id: tenantId, deletedAt: undefined }, + }); + + if (!tenant) { + return { allowed: false, reason: 'Tenant no encontrado' }; + } + + if (tenant.status !== TenantStatus.ACTIVE) { + return { allowed: false, reason: 'Tenant no está activo' }; + } + + const currentUsers = await this.userRepository.count({ + where: { tenantId, deletedAt: undefined }, + }); + + if (currentUsers >= tenant.maxUsers) { + return { + allowed: false, + reason: `Se ha alcanzado el límite de usuarios (${tenant.maxUsers})`, + }; + } + + return { allowed: true }; + } catch (error) { + logger.error('Error checking user limit', { + error: (error as Error).message, + tenantId, + }); + return { allowed: false, reason: 'Error verificando límite de usuarios' }; + } + } +} + +// ===== Export Singleton Instance ===== + +export const tenantsService = new TenantsService(); diff --git a/src/modules/users/index.ts b/src/modules/users/index.ts new file mode 100644 index 0000000..e7fab79 --- /dev/null +++ b/src/modules/users/index.ts @@ -0,0 +1,3 @@ +export * from './users.service.js'; +export * from './users.controller.js'; +export { default as usersRoutes } from './users.routes.js'; diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts new file mode 100644 index 0000000..6c45d84 --- /dev/null +++ b/src/modules/users/users.controller.ts @@ -0,0 +1,260 @@ +import { Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { usersService } from './users.service.js'; +import { ApiResponse, AuthenticatedRequest, ValidationError, PaginationParams } from '../../shared/types/index.js'; + +const createUserSchema = z.object({ + email: z.string().email('Email inválido'), + password: z.string().min(8, 'La contraseña debe tener al menos 8 caracteres'), + // Soporta ambos formatos: full_name (legacy) o firstName+lastName (frontend) + full_name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres').optional(), + firstName: z.string().min(2, 'Nombre debe tener al menos 2 caracteres').optional(), + lastName: z.string().min(2, 'Apellido debe tener al menos 2 caracteres').optional(), + status: z.enum(['active', 'inactive', 'pending']).optional(), + is_superuser: z.boolean().optional(), +}).refine( + (data) => data.full_name || (data.firstName && data.lastName), + { message: 'Se requiere full_name o firstName y lastName', path: ['full_name'] } +); + +const updateUserSchema = z.object({ + email: z.string().email('Email inválido').optional(), + full_name: z.string().min(2).optional(), + firstName: z.string().min(2).optional(), + lastName: z.string().min(2).optional(), + status: z.enum(['active', 'inactive', 'pending', 'suspended']).optional(), +}); + +const assignRoleSchema = z.object({ + role_id: z.string().uuid('Role ID inválido'), +}); + +export class UsersController { + async getMe(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const userId = req.user!.userId; + + const user = await usersService.findById(tenantId, userId); + + const response: ApiResponse = { + success: true, + data: user, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const page = parseInt(req.query.page as string) || 1; + const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); + const sortBy = req.query.sortBy as string; + const sortOrder = req.query.sortOrder as 'asc' | 'desc'; + + const params: PaginationParams = { page, limit, sortBy, sortOrder }; + const result = await usersService.findAll(tenantId, params); + + const response: ApiResponse = { + success: true, + data: result.users, + meta: { + page, + limit, + total: result.total, + totalPages: Math.ceil(result.total / limit), + }, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async findById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const userId = req.params.id; + + const user = await usersService.findById(tenantId, userId); + + const response: ApiResponse = { + success: true, + data: user, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = createUserSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const tenantId = req.user!.tenantId; + const user = await usersService.create({ + ...validation.data, + tenant_id: tenantId, + }); + + const response: ApiResponse = { + success: true, + data: user, + message: 'Usuario creado exitosamente', + }; + + res.status(201).json(response); + } catch (error) { + next(error); + } + } + + async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = updateUserSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const tenantId = req.user!.tenantId; + const userId = req.params.id; + + const user = await usersService.update(tenantId, userId, validation.data); + + const response: ApiResponse = { + success: true, + data: user, + message: 'Usuario actualizado exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const userId = req.params.id; + + await usersService.delete(tenantId, userId); + + const response: ApiResponse = { + success: true, + message: 'Usuario eliminado exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async getRoles(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.params.id; + const roles = await usersService.getUserRoles(userId); + + const response: ApiResponse = { + success: true, + data: roles, + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async assignRole(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const validation = assignRoleSchema.safeParse(req.body); + if (!validation.success) { + throw new ValidationError('Datos inválidos', validation.error.errors); + } + + const userId = req.params.id; + await usersService.assignRole(userId, validation.data.role_id); + + const response: ApiResponse = { + success: true, + message: 'Rol asignado exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async removeRole(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const userId = req.params.id; + const roleId = req.params.roleId; + + await usersService.removeRole(userId, roleId); + + const response: ApiResponse = { + success: true, + message: 'Rol removido exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async activate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const userId = req.params.id; + const currentUserId = req.user!.userId; + + const user = await usersService.activate(tenantId, userId, currentUserId); + + const response: ApiResponse = { + success: true, + data: user, + message: 'Usuario activado exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } + + async deactivate(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.user!.tenantId; + const userId = req.params.id; + const currentUserId = req.user!.userId; + + const user = await usersService.deactivate(tenantId, userId, currentUserId); + + const response: ApiResponse = { + success: true, + data: user, + message: 'Usuario desactivado exitosamente', + }; + + res.json(response); + } catch (error) { + next(error); + } + } +} + +export const usersController = new UsersController(); diff --git a/src/modules/users/users.routes.ts b/src/modules/users/users.routes.ts new file mode 100644 index 0000000..1add501 --- /dev/null +++ b/src/modules/users/users.routes.ts @@ -0,0 +1,60 @@ +import { Router } from 'express'; +import { usersController } from './users.controller.js'; +import { authenticate, requireRoles } from '../../shared/middleware/auth.middleware.js'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// Get current user profile +router.get('/me', (req, res, next) => usersController.getMe(req, res, next)); + +// List users (admin, manager) +router.get('/', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + usersController.findAll(req, res, next) +); + +// Get user by ID +router.get('/:id', requireRoles('admin', 'manager', 'super_admin'), (req, res, next) => + usersController.findById(req, res, next) +); + +// Create user (admin only) +router.post('/', requireRoles('admin', 'super_admin'), (req, res, next) => + usersController.create(req, res, next) +); + +// Update user (admin only) +router.put('/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + usersController.update(req, res, next) +); + +// Delete user (admin only) +router.delete('/:id', requireRoles('admin', 'super_admin'), (req, res, next) => + usersController.delete(req, res, next) +); + +// Activate/Deactivate user (admin only) +router.post('/:id/activate', requireRoles('admin', 'super_admin'), (req, res, next) => + usersController.activate(req, res, next) +); + +router.post('/:id/deactivate', requireRoles('admin', 'super_admin'), (req, res, next) => + usersController.deactivate(req, res, next) +); + +// User roles +router.get('/:id/roles', requireRoles('admin', 'super_admin'), (req, res, next) => + usersController.getRoles(req, res, next) +); + +router.post('/:id/roles', requireRoles('admin', 'super_admin'), (req, res, next) => + usersController.assignRole(req, res, next) +); + +router.delete('/:id/roles/:roleId', requireRoles('admin', 'super_admin'), (req, res, next) => + usersController.removeRole(req, res, next) +); + +export default router; diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts new file mode 100644 index 0000000..a2f63c9 --- /dev/null +++ b/src/modules/users/users.service.ts @@ -0,0 +1,372 @@ +import bcrypt from 'bcryptjs'; +import { Repository, IsNull } from 'typeorm'; +import { AppDataSource } from '../../config/typeorm.js'; +import { User, UserStatus, Role } from '../auth/entities/index.js'; +import { NotFoundError, ValidationError } from '../../shared/types/index.js'; +import { logger } from '../../shared/utils/logger.js'; +import { splitFullName, buildFullName } from '../auth/auth.service.js'; + +export interface CreateUserDto { + tenant_id: string; + email: string; + password: string; + full_name?: string; + firstName?: string; + lastName?: string; + status?: UserStatus | 'active' | 'inactive' | 'pending'; + is_superuser?: boolean; +} + +export interface UpdateUserDto { + email?: string; + full_name?: string; + firstName?: string; + lastName?: string; + status?: UserStatus | 'active' | 'inactive' | 'pending' | 'suspended'; +} + +export interface UserListParams { + page: number; + limit: number; + search?: string; + status?: UserStatus | 'active' | 'inactive' | 'pending' | 'suspended'; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} + +export interface UserResponse { + id: string; + tenantId: string; + email: string; + fullName: string; + firstName: string; + lastName: string; + avatarUrl: string | null; + status: UserStatus; + isSuperuser: boolean; + emailVerifiedAt: Date | null; + lastLoginAt: Date | null; + lastLoginIp: string | null; + loginCount: number; + language: string; + timezone: string; + settings: Record; + createdAt: Date; + updatedAt: Date | null; + roles?: Role[]; +} + +/** + * Transforma usuario de BD a formato frontend (con firstName/lastName) + */ +function transformUserResponse(user: User): UserResponse { + const { passwordHash, ...rest } = user; + const { firstName, lastName } = splitFullName(user.fullName || ''); + return { + ...rest, + firstName, + lastName, + roles: user.roles, + }; +} + +export interface UsersListResult { + users: UserResponse[]; + total: number; +} + +class UsersService { + private userRepository: Repository; + private roleRepository: Repository; + + constructor() { + this.userRepository = AppDataSource.getRepository(User); + this.roleRepository = AppDataSource.getRepository(Role); + } + + async findAll(tenantId: string, params: UserListParams): Promise { + const { + page, + limit, + search, + status, + sortBy = 'createdAt', + sortOrder = 'desc' + } = params; + + const skip = (page - 1) * limit; + + // Mapa de campos para ordenamiento (frontend -> entity) + const sortFieldMap: Record = { + createdAt: 'user.createdAt', + email: 'user.email', + fullName: 'user.fullName', + status: 'user.status', + }; + + const orderField = sortFieldMap[sortBy] || 'user.createdAt'; + const orderDirection = sortOrder.toUpperCase() as 'ASC' | 'DESC'; + + // Crear QueryBuilder + const queryBuilder = this.userRepository + .createQueryBuilder('user') + .where('user.tenantId = :tenantId', { tenantId }) + .andWhere('user.deletedAt IS NULL'); + + // Filtrar por búsqueda (email o fullName) + if (search) { + queryBuilder.andWhere( + '(user.email ILIKE :search OR user.fullName ILIKE :search)', + { search: `%${search}%` } + ); + } + + // Filtrar por status + if (status) { + queryBuilder.andWhere('user.status = :status', { status }); + } + + // Obtener total y usuarios con paginación + const [users, total] = await queryBuilder + .orderBy(orderField, orderDirection) + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { + users: users.map(transformUserResponse), + total, + }; + } + + async findById(tenantId: string, userId: string): Promise { + const user = await this.userRepository.findOne({ + where: { + id: userId, + tenantId, + deletedAt: IsNull(), + }, + relations: ['roles'], + }); + + if (!user) { + throw new NotFoundError('Usuario no encontrado'); + } + + return transformUserResponse(user); + } + + async create(dto: CreateUserDto): Promise { + // Check if email already exists + const existingUser = await this.userRepository.findOne({ + where: { email: dto.email.toLowerCase() }, + }); + + if (existingUser) { + throw new ValidationError('El email ya está registrado'); + } + + // Transformar firstName/lastName a fullName para almacenar en BD + const fullName = buildFullName(dto.firstName, dto.lastName, dto.full_name); + + const passwordHash = await bcrypt.hash(dto.password, 10); + + // Crear usuario con repository + const user = this.userRepository.create({ + tenantId: dto.tenant_id, + email: dto.email.toLowerCase(), + passwordHash, + fullName, + status: dto.status as UserStatus || UserStatus.ACTIVE, + isSuperuser: dto.is_superuser || false, + }); + + const savedUser = await this.userRepository.save(user); + + logger.info('User created', { userId: savedUser.id, email: savedUser.email }); + return transformUserResponse(savedUser); + } + + async update(tenantId: string, userId: string, dto: UpdateUserDto): Promise { + // Obtener usuario existente + const user = await this.userRepository.findOne({ + where: { + id: userId, + tenantId, + deletedAt: IsNull(), + }, + }); + + if (!user) { + throw new NotFoundError('Usuario no encontrado'); + } + + // Check email uniqueness if changing + if (dto.email && dto.email.toLowerCase() !== user.email) { + const emailExists = await this.userRepository.findOne({ + where: { + email: dto.email.toLowerCase(), + }, + }); + if (emailExists && emailExists.id !== userId) { + throw new ValidationError('El email ya está en uso'); + } + } + + // Actualizar campos + if (dto.email !== undefined) { + user.email = dto.email.toLowerCase(); + } + + // Soportar firstName/lastName o full_name + const fullName = buildFullName(dto.firstName, dto.lastName, dto.full_name); + if (fullName) { + user.fullName = fullName; + } + + if (dto.status !== undefined) { + user.status = dto.status as UserStatus; + } + + const updatedUser = await this.userRepository.save(user); + + logger.info('User updated', { userId: updatedUser.id }); + return transformUserResponse(updatedUser); + } + + async delete(tenantId: string, userId: string, currentUserId?: string): Promise { + // Obtener usuario para soft delete + const user = await this.userRepository.findOne({ + where: { + id: userId, + tenantId, + deletedAt: IsNull(), + }, + }); + + if (!user) { + throw new NotFoundError('Usuario no encontrado'); + } + + // Soft delete real con deletedAt y deletedBy + user.deletedAt = new Date(); + if (currentUserId) { + user.deletedBy = currentUserId; + } + await this.userRepository.save(user); + + logger.info('User deleted (soft)', { userId, deletedBy: currentUserId || 'unknown' }); + } + + async activate(tenantId: string, userId: string, currentUserId: string): Promise { + const user = await this.userRepository.findOne({ + where: { + id: userId, + tenantId, + deletedAt: IsNull(), + }, + relations: ['roles'], + }); + + if (!user) { + throw new NotFoundError('Usuario no encontrado'); + } + + user.status = UserStatus.ACTIVE; + user.updatedBy = currentUserId; + const updatedUser = await this.userRepository.save(user); + + logger.info('User activated', { userId, activatedBy: currentUserId }); + return transformUserResponse(updatedUser); + } + + async deactivate(tenantId: string, userId: string, currentUserId: string): Promise { + const user = await this.userRepository.findOne({ + where: { + id: userId, + tenantId, + deletedAt: IsNull(), + }, + relations: ['roles'], + }); + + if (!user) { + throw new NotFoundError('Usuario no encontrado'); + } + + user.status = UserStatus.INACTIVE; + user.updatedBy = currentUserId; + const updatedUser = await this.userRepository.save(user); + + logger.info('User deactivated', { userId, deactivatedBy: currentUserId }); + return transformUserResponse(updatedUser); + } + + async assignRole(userId: string, roleId: string): Promise { + // Obtener usuario con roles + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['roles'], + }); + + if (!user) { + throw new NotFoundError('Usuario no encontrado'); + } + + // Obtener rol + const role = await this.roleRepository.findOne({ + where: { id: roleId }, + }); + + if (!role) { + throw new NotFoundError('Rol no encontrado'); + } + + // Verificar si ya tiene el rol + const hasRole = user.roles?.some(r => r.id === roleId); + if (!hasRole) { + if (!user.roles) { + user.roles = []; + } + user.roles.push(role); + await this.userRepository.save(user); + } + + logger.info('Role assigned to user', { userId, roleId }); + } + + async removeRole(userId: string, roleId: string): Promise { + // Obtener usuario con roles + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['roles'], + }); + + if (!user) { + throw new NotFoundError('Usuario no encontrado'); + } + + // Filtrar el rol a eliminar + if (user.roles) { + user.roles = user.roles.filter(r => r.id !== roleId); + await this.userRepository.save(user); + } + + logger.info('Role removed from user', { userId, roleId }); + } + + async getUserRoles(userId: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ['roles'], + }); + + if (!user) { + throw new NotFoundError('Usuario no encontrado'); + } + + return user.roles || []; + } +} + +export const usersService = new UsersService(); diff --git a/src/shared/errors/index.ts b/src/shared/errors/index.ts new file mode 100644 index 0000000..93cdde0 --- /dev/null +++ b/src/shared/errors/index.ts @@ -0,0 +1,18 @@ +// Re-export all error classes from types +export { + AppError, + ValidationError, + UnauthorizedError, + ForbiddenError, + NotFoundError, +} from '../types/index.js'; + +// Additional error class not in types +import { AppError } from '../types/index.js'; + +export class ConflictError extends AppError { + constructor(message: string = 'Conflicto con el recurso existente') { + super(message, 409, 'CONFLICT'); + this.name = 'ConflictError'; + } +} diff --git a/src/shared/middleware/apiKeyAuth.middleware.ts b/src/shared/middleware/apiKeyAuth.middleware.ts new file mode 100644 index 0000000..db513da --- /dev/null +++ b/src/shared/middleware/apiKeyAuth.middleware.ts @@ -0,0 +1,217 @@ +import { Response, NextFunction } from 'express'; +import { apiKeysService } from '../../modules/auth/apiKeys.service.js'; +import { AuthenticatedRequest, UnauthorizedError, ForbiddenError } from '../types/index.js'; +import { logger } from '../utils/logger.js'; + +// ============================================================================ +// API KEY AUTHENTICATION MIDDLEWARE +// ============================================================================ + +/** + * Header name for API Key authentication + * Supports both X-API-Key and Authorization: ApiKey xxx + */ +const API_KEY_HEADER = 'x-api-key'; +const API_KEY_AUTH_PREFIX = 'ApiKey '; + +/** + * Extract API key from request headers + */ +function extractApiKey(req: AuthenticatedRequest): string | null { + // Check X-API-Key header first + const xApiKey = req.headers[API_KEY_HEADER] as string; + if (xApiKey) { + return xApiKey; + } + + // Check Authorization header with ApiKey prefix + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith(API_KEY_AUTH_PREFIX)) { + return authHeader.substring(API_KEY_AUTH_PREFIX.length); + } + + return null; +} + +/** + * Get client IP address from request + */ +function getClientIp(req: AuthenticatedRequest): string | undefined { + // Check X-Forwarded-For header (for proxies/load balancers) + const forwardedFor = req.headers['x-forwarded-for']; + if (forwardedFor) { + const ips = (forwardedFor as string).split(','); + return ips[0].trim(); + } + + // Check X-Real-IP header + const realIp = req.headers['x-real-ip'] as string; + if (realIp) { + return realIp; + } + + // Fallback to socket remote address + return req.socket.remoteAddress; +} + +/** + * Authenticate request using API Key + * Use this middleware for API endpoints that should accept API Key authentication + */ +export function authenticateApiKey( + req: AuthenticatedRequest, + _res: Response, + next: NextFunction +): void { + (async () => { + try { + const apiKey = extractApiKey(req); + + if (!apiKey) { + throw new UnauthorizedError('API key requerida'); + } + + const clientIp = getClientIp(req); + const result = await apiKeysService.validate(apiKey, clientIp); + + if (!result.valid || !result.user) { + logger.warn('API key validation failed', { + error: result.error, + clientIp, + }); + throw new UnauthorizedError(result.error || 'API key inválida'); + } + + // Set user info on request (same format as JWT auth) + req.user = { + userId: result.user.id, + tenantId: result.user.tenant_id, + email: result.user.email, + roles: result.user.roles, + }; + req.tenantId = result.user.tenant_id; + + // Mark request as authenticated via API Key (for logging/audit) + (req as any).authMethod = 'api_key'; + (req as any).apiKeyId = result.apiKey?.id; + + next(); + } catch (error) { + next(error); + } + })(); +} + +/** + * Authenticate request using either JWT or API Key + * Use this for endpoints that should accept both authentication methods + */ +export function authenticateJwtOrApiKey( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): void { + const apiKey = extractApiKey(req); + const jwtToken = req.headers.authorization?.startsWith('Bearer '); + + if (apiKey) { + // Use API Key authentication + authenticateApiKey(req, res, next); + } else if (jwtToken) { + // Use JWT authentication - import dynamically to avoid circular deps + import('./auth.middleware.js').then(({ authenticate }) => { + authenticate(req, res, next); + }); + } else { + next(new UnauthorizedError('Autenticación requerida (JWT o API Key)')); + } +} + +/** + * Require specific API key scope + * Use after authenticateApiKey to enforce scope restrictions + */ +export function requireApiKeyScope(requiredScope: string) { + return (req: AuthenticatedRequest, _res: Response, next: NextFunction): void => { + try { + const apiKeyId = (req as any).apiKeyId; + const authMethod = (req as any).authMethod; + + // Only check scope for API Key auth + if (authMethod !== 'api_key') { + return next(); + } + + // Get API key scope from database (cached in validation result) + // For now, we'll re-validate - in production, cache this + (async () => { + const apiKey = extractApiKey(req); + if (!apiKey) { + throw new ForbiddenError('API key no encontrada'); + } + + const result = await apiKeysService.validate(apiKey); + if (!result.valid || !result.apiKey) { + throw new ForbiddenError('API key inválida'); + } + + // Null scope means full access + if (result.apiKey.scope === null) { + return next(); + } + + // Check if scope matches + if (result.apiKey.scope !== requiredScope) { + logger.warn('API key scope mismatch', { + apiKeyId, + requiredScope, + actualScope: result.apiKey.scope, + }); + throw new ForbiddenError(`API key no tiene el scope requerido: ${requiredScope}`); + } + + next(); + })(); + } catch (error) { + next(error); + } + }; +} + +/** + * Rate limiting for API Key requests + * Simple in-memory rate limiter - use Redis in production + */ +const rateLimitStore = new Map(); + +export function apiKeyRateLimit(maxRequests: number = 1000, windowMs: number = 60000) { + return (req: AuthenticatedRequest, _res: Response, next: NextFunction): void => { + try { + const apiKeyId = (req as any).apiKeyId; + if (!apiKeyId) { + return next(); + } + + const now = Date.now(); + const record = rateLimitStore.get(apiKeyId); + + if (!record || now > record.resetTime) { + rateLimitStore.set(apiKeyId, { + count: 1, + resetTime: now + windowMs, + }); + return next(); + } + + if (record.count >= maxRequests) { + logger.warn('API key rate limit exceeded', { apiKeyId, count: record.count }); + throw new ForbiddenError('Rate limit excedido. Intente más tarde.'); + } + + record.count++; + next(); + } catch (error) { + next(error); + } + }; +} diff --git a/src/shared/middleware/auth.middleware.ts b/src/shared/middleware/auth.middleware.ts new file mode 100644 index 0000000..a502890 --- /dev/null +++ b/src/shared/middleware/auth.middleware.ts @@ -0,0 +1,119 @@ +import { Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { config } from '../../config/index.js'; +import { AuthenticatedRequest, JwtPayload, UnauthorizedError, ForbiddenError } from '../types/index.js'; +import { logger } from '../utils/logger.js'; + +// Re-export AuthenticatedRequest for convenience +export { AuthenticatedRequest } from '../types/index.js'; + +export function authenticate( + req: AuthenticatedRequest, + _res: Response, + next: NextFunction +): void { + try { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + throw new UnauthorizedError('Token de acceso requerido'); + } + + const token = authHeader.substring(7); + + try { + const payload = jwt.verify(token, config.jwt.secret) as JwtPayload; + req.user = payload; + req.tenantId = payload.tenantId; + next(); + } catch (error) { + if (error instanceof jwt.TokenExpiredError) { + throw new UnauthorizedError('Token expirado'); + } + throw new UnauthorizedError('Token inválido'); + } + } catch (error) { + next(error); + } +} + +export function requireRoles(...roles: string[]) { + return (req: AuthenticatedRequest, _res: Response, next: NextFunction): void => { + try { + if (!req.user) { + throw new UnauthorizedError('Usuario no autenticado'); + } + + // Superusers bypass role checks + if (req.user.roles.includes('super_admin')) { + return next(); + } + + const hasRole = roles.some(role => req.user!.roles.includes(role)); + if (!hasRole) { + logger.warn('Access denied - insufficient roles', { + userId: req.user.userId, + requiredRoles: roles, + userRoles: req.user.roles, + }); + throw new ForbiddenError('No tiene permisos para esta acción'); + } + + next(); + } catch (error) { + next(error); + } + }; +} + +export function requirePermission(resource: string, action: string) { + return async (req: AuthenticatedRequest, _res: Response, next: NextFunction): Promise => { + try { + if (!req.user) { + throw new UnauthorizedError('Usuario no autenticado'); + } + + // Superusers bypass permission checks + if (req.user.roles.includes('super_admin')) { + return next(); + } + + // TODO: Check permission in database + // For now, we'll implement this when we have the permission checking service + logger.debug('Permission check', { + userId: req.user.userId, + resource, + action, + }); + + next(); + } catch (error) { + next(error); + } + }; +} + +export function optionalAuth( + req: AuthenticatedRequest, + _res: Response, + next: NextFunction +): void { + try { + const authHeader = req.headers.authorization; + + if (authHeader && authHeader.startsWith('Bearer ')) { + const token = authHeader.substring(7); + try { + const payload = jwt.verify(token, config.jwt.secret) as JwtPayload; + req.user = payload; + req.tenantId = payload.tenantId; + } catch { + // Token invalid, but that's okay for optional auth + } + } + + next(); + } catch (error) { + next(error); + } +} diff --git a/src/shared/middleware/fieldPermissions.middleware.ts b/src/shared/middleware/fieldPermissions.middleware.ts new file mode 100644 index 0000000..1658168 --- /dev/null +++ b/src/shared/middleware/fieldPermissions.middleware.ts @@ -0,0 +1,343 @@ +import { Response, NextFunction } from 'express'; +import { query, queryOne } from '../../config/database.js'; +import { AuthenticatedRequest } from '../types/index.js'; +import { logger } from '../utils/logger.js'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface FieldPermission { + field_name: string; + can_read: boolean; + can_write: boolean; +} + +export interface ModelFieldPermissions { + model_name: string; + fields: Map; +} + +// Cache for field permissions per user/model +const permissionsCache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** + * Get cache key for user/model combination + */ +function getCacheKey(userId: string, tenantId: string, modelName: string): string { + return `${tenantId}:${userId}:${modelName}`; +} + +/** + * Load field permissions for a user on a specific model + */ +async function loadFieldPermissions( + userId: string, + tenantId: string, + modelName: string +): Promise { + // Check cache first + const cacheKey = getCacheKey(userId, tenantId, modelName); + const cached = permissionsCache.get(cacheKey); + + if (cached && cached.expires > Date.now()) { + return cached.permissions; + } + + // Load from database + const result = await query<{ + field_name: string; + can_read: boolean; + can_write: boolean; + }>( + `SELECT + mf.name as field_name, + COALESCE(fp.can_read, true) as can_read, + COALESCE(fp.can_write, true) as can_write + FROM auth.model_fields mf + JOIN auth.models m ON mf.model_id = m.id + LEFT JOIN auth.field_permissions fp ON mf.id = fp.field_id + LEFT JOIN auth.user_groups ug ON fp.group_id = ug.group_id + WHERE m.model = $1 + AND m.tenant_id = $2 + AND (ug.user_id = $3 OR fp.group_id IS NULL) + GROUP BY mf.name, fp.can_read, fp.can_write`, + [modelName, tenantId, userId] + ); + + if (result.length === 0) { + // No permissions defined = allow all + return null; + } + + const permissions: ModelFieldPermissions = { + model_name: modelName, + fields: new Map(), + }; + + for (const row of result) { + permissions.fields.set(row.field_name, { + field_name: row.field_name, + can_read: row.can_read, + can_write: row.can_write, + }); + } + + // Cache the result + permissionsCache.set(cacheKey, { + permissions, + expires: Date.now() + CACHE_TTL, + }); + + return permissions; +} + +/** + * Filter object fields based on read permissions + */ +function filterReadFields>( + data: T, + permissions: ModelFieldPermissions | null +): Partial { + // No permissions defined = return all fields + if (!permissions || permissions.fields.size === 0) { + return data; + } + + const filtered: Record = {}; + + for (const [key, value] of Object.entries(data)) { + const fieldPerm = permissions.fields.get(key); + + // If no permission defined for field, allow it + // If permission exists and can_read is true, allow it + if (!fieldPerm || fieldPerm.can_read) { + filtered[key] = value; + } + } + + return filtered as Partial; +} + +/** + * Filter array of objects + */ +function filterReadFieldsArray>( + data: T[], + permissions: ModelFieldPermissions | null +): Partial[] { + return data.map(item => filterReadFields(item, permissions)); +} + +/** + * Validate write permissions for incoming data + */ +function validateWriteFields>( + data: T, + permissions: ModelFieldPermissions | null +): { valid: boolean; forbiddenFields: string[] } { + // No permissions defined = allow all writes + if (!permissions || permissions.fields.size === 0) { + return { valid: true, forbiddenFields: [] }; + } + + const forbiddenFields: string[] = []; + + for (const key of Object.keys(data)) { + const fieldPerm = permissions.fields.get(key); + + // If permission exists and can_write is false, it's forbidden + if (fieldPerm && !fieldPerm.can_write) { + forbiddenFields.push(key); + } + } + + return { + valid: forbiddenFields.length === 0, + forbiddenFields, + }; +} + +// ============================================================================ +// MIDDLEWARE FACTORIES +// ============================================================================ + +/** + * Middleware to filter response fields based on read permissions + * Use this on GET endpoints + */ +export function filterResponseFields(modelName: string) { + return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise => { + // Store original json method + const originalJson = res.json.bind(res); + + // Override json method to filter fields + res.json = function(body: any) { + (async () => { + try { + // Only filter for authenticated requests + if (!req.user) { + return originalJson(body); + } + + // Load permissions + const permissions = await loadFieldPermissions( + req.user.userId, + req.user.tenantId, + modelName + ); + + // If no permissions defined or super_admin, return original + if (!permissions || req.user.roles.includes('super_admin')) { + return originalJson(body); + } + + // Filter the response + if (body && typeof body === 'object') { + if (body.data) { + if (Array.isArray(body.data)) { + body.data = filterReadFieldsArray(body.data, permissions); + } else if (typeof body.data === 'object') { + body.data = filterReadFields(body.data, permissions); + } + } else if (Array.isArray(body)) { + body = filterReadFieldsArray(body, permissions); + } + } + + return originalJson(body); + } catch (error) { + logger.error('Error filtering response fields', { error, modelName }); + return originalJson(body); + } + })(); + } as typeof res.json; + + next(); + }; +} + +/** + * Middleware to validate write permissions on incoming data + * Use this on POST/PUT/PATCH endpoints + */ +export function validateWritePermissions(modelName: string) { + return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise => { + try { + // Skip for unauthenticated requests (they'll fail auth anyway) + if (!req.user) { + return next(); + } + + // Super admins bypass field permission checks + if (req.user.roles.includes('super_admin')) { + return next(); + } + + // Load permissions + const permissions = await loadFieldPermissions( + req.user.userId, + req.user.tenantId, + modelName + ); + + // No permissions defined = allow all + if (!permissions) { + return next(); + } + + // Validate write fields in request body + if (req.body && typeof req.body === 'object') { + const { valid, forbiddenFields } = validateWriteFields(req.body, permissions); + + if (!valid) { + logger.warn('Write permission denied for fields', { + userId: req.user.userId, + modelName, + forbiddenFields, + }); + + res.status(403).json({ + success: false, + error: `No tiene permisos para modificar los campos: ${forbiddenFields.join(', ')}`, + forbiddenFields, + }); + return; + } + } + + next(); + } catch (error) { + logger.error('Error validating write permissions', { error, modelName }); + next(error); + } + }; +} + +/** + * Combined middleware for both read and write validation + */ +export function fieldPermissions(modelName: string) { + const readFilter = filterResponseFields(modelName); + const writeValidator = validateWritePermissions(modelName); + + return async (req: AuthenticatedRequest, res: Response, next: NextFunction): Promise => { + // For write operations, validate first + if (['POST', 'PUT', 'PATCH'].includes(req.method)) { + await writeValidator(req, res, () => { + // If write validation passed, apply read filter for response + readFilter(req, res, next); + }); + } else { + // For read operations, just apply read filter + await readFilter(req, res, next); + } + }; +} + +/** + * Clear permissions cache for a user (call after permission changes) + */ +export function clearPermissionsCache(userId?: string, tenantId?: string): void { + if (userId && tenantId) { + // Clear specific user's cache + const prefix = `${tenantId}:${userId}:`; + for (const key of permissionsCache.keys()) { + if (key.startsWith(prefix)) { + permissionsCache.delete(key); + } + } + } else { + // Clear all cache + permissionsCache.clear(); + } +} + +/** + * Get list of restricted fields for a user on a model + * Useful for frontend to know which fields to hide/disable + */ +export async function getRestrictedFields( + userId: string, + tenantId: string, + modelName: string +): Promise<{ readRestricted: string[]; writeRestricted: string[] }> { + const permissions = await loadFieldPermissions(userId, tenantId, modelName); + + const readRestricted: string[] = []; + const writeRestricted: string[] = []; + + if (permissions) { + for (const [fieldName, perm] of permissions.fields) { + if (!perm.can_read) readRestricted.push(fieldName); + if (!perm.can_write) writeRestricted.push(fieldName); + } + } + + return { readRestricted, writeRestricted }; +} diff --git a/src/shared/services/base.service.ts b/src/shared/services/base.service.ts new file mode 100644 index 0000000..73ea039 --- /dev/null +++ b/src/shared/services/base.service.ts @@ -0,0 +1,429 @@ +import { query, queryOne, getClient, PoolClient } from '../../config/database.js'; +import { NotFoundError, ValidationError } from '../errors/index.js'; +import { PaginationMeta } from '../types/index.js'; + +/** + * Resultado paginado genérico + */ +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +/** + * Filtros de paginación base + */ +export interface BasePaginationFilters { + page?: number; + limit?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + search?: string; +} + +/** + * Opciones para construcción de queries + */ +export interface QueryOptions { + client?: PoolClient; + includeDeleted?: boolean; +} + +/** + * Configuración del servicio base + */ +export interface BaseServiceConfig { + tableName: string; + schema: string; + selectFields: string; + searchFields?: string[]; + defaultSortField?: string; + softDelete?: boolean; +} + +/** + * Clase base abstracta para servicios CRUD con soporte multi-tenant + * + * Proporciona implementaciones reutilizables para: + * - Paginación con filtros + * - Búsqueda por texto + * - CRUD básico + * - Soft delete + * - Transacciones + * + * @example + * ```typescript + * class PartnersService extends BaseService { + * protected config: BaseServiceConfig = { + * tableName: 'partners', + * schema: 'core', + * selectFields: 'id, tenant_id, name, email, phone, created_at', + * searchFields: ['name', 'email', 'tax_id'], + * defaultSortField: 'name', + * softDelete: true, + * }; + * } + * ``` + */ +export abstract class BaseService { + protected abstract config: BaseServiceConfig; + + /** + * Nombre completo de la tabla (schema.table) + */ + protected get fullTableName(): string { + return `${this.config.schema}.${this.config.tableName}`; + } + + /** + * Obtiene todos los registros con paginación y filtros + */ + async findAll( + tenantId: string, + filters: BasePaginationFilters & Record = {}, + options: QueryOptions = {} + ): Promise> { + const { + page = 1, + limit = 20, + sortBy = this.config.defaultSortField || 'created_at', + sortOrder = 'desc', + search, + ...customFilters + } = filters; + + const offset = (page - 1) * limit; + const params: any[] = [tenantId]; + let paramIndex = 2; + + // Construir WHERE clause + let whereClause = 'WHERE tenant_id = $1'; + + // Soft delete + if (this.config.softDelete && !options.includeDeleted) { + whereClause += ' AND deleted_at IS NULL'; + } + + // Búsqueda por texto + if (search && this.config.searchFields?.length) { + const searchConditions = this.config.searchFields + .map(field => `${field} ILIKE $${paramIndex}`) + .join(' OR '); + whereClause += ` AND (${searchConditions})`; + params.push(`%${search}%`); + paramIndex++; + } + + // Filtros custom + for (const [key, value] of Object.entries(customFilters)) { + if (value !== undefined && value !== null && value !== '') { + whereClause += ` AND ${key} = $${paramIndex++}`; + params.push(value); + } + } + + // Validar sortBy para prevenir SQL injection + const safeSortBy = this.sanitizeFieldName(sortBy); + const safeSortOrder = sortOrder === 'asc' ? 'ASC' : 'DESC'; + + // Query de conteo + const countSql = ` + SELECT COUNT(*) as count + FROM ${this.fullTableName} + ${whereClause} + `; + + // Query de datos + const dataSql = ` + SELECT ${this.config.selectFields} + FROM ${this.fullTableName} + ${whereClause} + ORDER BY ${safeSortBy} ${safeSortOrder} + LIMIT $${paramIndex++} OFFSET $${paramIndex} + `; + + if (options.client) { + const [countResult, dataResult] = await Promise.all([ + options.client.query(countSql, params), + options.client.query(dataSql, [...params, limit, offset]), + ]); + + const total = parseInt(countResult.rows[0]?.count || '0', 10); + + return { + data: dataResult.rows as T[], + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + const [countRows, dataRows] = await Promise.all([ + query<{ count: string }>(countSql, params), + query(dataSql, [...params, limit, offset]), + ]); + + const total = parseInt(countRows[0]?.count || '0', 10); + + return { + data: dataRows, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Obtiene un registro por ID + */ + async findById( + id: string, + tenantId: string, + options: QueryOptions = {} + ): Promise { + let whereClause = 'WHERE id = $1 AND tenant_id = $2'; + + if (this.config.softDelete && !options.includeDeleted) { + whereClause += ' AND deleted_at IS NULL'; + } + + const sql = ` + SELECT ${this.config.selectFields} + FROM ${this.fullTableName} + ${whereClause} + `; + + if (options.client) { + const result = await options.client.query(sql, [id, tenantId]); + return result.rows[0] as T || null; + } + const rows = await query(sql, [id, tenantId]); + return rows[0] || null; + } + + /** + * Obtiene un registro por ID o lanza error si no existe + */ + async findByIdOrFail( + id: string, + tenantId: string, + options: QueryOptions = {} + ): Promise { + const entity = await this.findById(id, tenantId, options); + if (!entity) { + throw new NotFoundError(`${this.config.tableName} with id ${id} not found`); + } + return entity; + } + + /** + * Verifica si existe un registro + */ + async exists( + id: string, + tenantId: string, + options: QueryOptions = {} + ): Promise { + let whereClause = 'WHERE id = $1 AND tenant_id = $2'; + + if (this.config.softDelete && !options.includeDeleted) { + whereClause += ' AND deleted_at IS NULL'; + } + + const sql = ` + SELECT 1 FROM ${this.fullTableName} + ${whereClause} + LIMIT 1 + `; + + if (options.client) { + const result = await options.client.query(sql, [id, tenantId]); + return result.rows.length > 0; + } + const rows = await query(sql, [id, tenantId]); + return rows.length > 0; + } + + /** + * Soft delete de un registro + */ + async softDelete( + id: string, + tenantId: string, + userId: string, + options: QueryOptions = {} + ): Promise { + if (!this.config.softDelete) { + throw new ValidationError('Soft delete not enabled for this entity'); + } + + const sql = ` + UPDATE ${this.fullTableName} + SET deleted_at = CURRENT_TIMESTAMP, + deleted_by = $3, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL + RETURNING id + `; + + if (options.client) { + const result = await options.client.query(sql, [id, tenantId, userId]); + return result.rows.length > 0; + } + const rows = await query(sql, [id, tenantId, userId]); + return rows.length > 0; + } + + /** + * Hard delete de un registro + */ + async hardDelete( + id: string, + tenantId: string, + options: QueryOptions = {} + ): Promise { + const sql = ` + DELETE FROM ${this.fullTableName} + WHERE id = $1 AND tenant_id = $2 + RETURNING id + `; + + if (options.client) { + const result = await options.client.query(sql, [id, tenantId]); + return result.rows.length > 0; + } + const rows = await query(sql, [id, tenantId]); + return rows.length > 0; + } + + /** + * Cuenta registros con filtros + */ + async count( + tenantId: string, + filters: Record = {}, + options: QueryOptions = {} + ): Promise { + const params: any[] = [tenantId]; + let paramIndex = 2; + let whereClause = 'WHERE tenant_id = $1'; + + if (this.config.softDelete && !options.includeDeleted) { + whereClause += ' AND deleted_at IS NULL'; + } + + for (const [key, value] of Object.entries(filters)) { + if (value !== undefined && value !== null) { + whereClause += ` AND ${key} = $${paramIndex++}`; + params.push(value); + } + } + + const sql = ` + SELECT COUNT(*) as count + FROM ${this.fullTableName} + ${whereClause} + `; + + if (options.client) { + const result = await options.client.query(sql, params); + return parseInt(result.rows[0]?.count || '0', 10); + } + const rows = await query<{ count: string }>(sql, params); + return parseInt(rows[0]?.count || '0', 10); + } + + /** + * Ejecuta una función dentro de una transacción + */ + protected async withTransaction( + fn: (client: PoolClient) => Promise + ): Promise { + const client = await getClient(); + try { + await client.query('BEGIN'); + const result = await fn(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } + } + + /** + * Sanitiza nombre de campo para prevenir SQL injection + */ + protected sanitizeFieldName(field: string): string { + // Solo permite caracteres alfanuméricos y guiones bajos + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field)) { + return this.config.defaultSortField || 'created_at'; + } + return field; + } + + /** + * Construye un INSERT dinámico + */ + protected buildInsertQuery( + data: Record, + additionalFields: Record = {} + ): { sql: string; params: any[] } { + const allData = { ...data, ...additionalFields }; + const fields = Object.keys(allData); + const values = Object.values(allData); + const placeholders = fields.map((_, i) => `$${i + 1}`); + + const sql = ` + INSERT INTO ${this.fullTableName} (${fields.join(', ')}) + VALUES (${placeholders.join(', ')}) + RETURNING ${this.config.selectFields} + `; + + return { sql, params: values }; + } + + /** + * Construye un UPDATE dinámico + */ + protected buildUpdateQuery( + id: string, + tenantId: string, + data: Record + ): { sql: string; params: any[] } { + const fields = Object.keys(data).filter(k => data[k] !== undefined); + const setClauses = fields.map((f, i) => `${f} = $${i + 1}`); + const values = fields.map(f => data[f]); + + // Agregar updated_at automáticamente + setClauses.push(`updated_at = CURRENT_TIMESTAMP`); + + const paramIndex = fields.length + 1; + + const sql = ` + UPDATE ${this.fullTableName} + SET ${setClauses.join(', ')} + WHERE id = $${paramIndex} AND tenant_id = $${paramIndex + 1} + RETURNING ${this.config.selectFields} + `; + + return { sql, params: [...values, id, tenantId] }; + } + + /** + * Redondea a N decimales + */ + protected roundToDecimals(value: number, decimals: number = 2): number { + const factor = Math.pow(10, decimals); + return Math.round(value * factor) / factor; + } +} + +export default BaseService; diff --git a/src/shared/services/index.ts b/src/shared/services/index.ts new file mode 100644 index 0000000..ff03ec0 --- /dev/null +++ b/src/shared/services/index.ts @@ -0,0 +1,7 @@ +export { + BaseService, + PaginatedResult, + BasePaginationFilters, + QueryOptions, + BaseServiceConfig, +} from './base.service.js'; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts new file mode 100644 index 0000000..f7a618e --- /dev/null +++ b/src/shared/types/index.ts @@ -0,0 +1,144 @@ +import { Request } from 'express'; + +// API Response types +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: string; + meta?: PaginationMeta; +} + +export interface PaginationMeta { + page: number; + limit: number; + total: number; + totalPages: number; +} + +export interface PaginationParams { + page: number; + limit: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} + +// Auth types +export interface JwtPayload { + userId: string; + tenantId: string; + email: string; + roles: string[]; + sessionId?: string; + jti?: string; + iat?: number; + exp?: number; +} + +export interface AuthenticatedRequest extends Request { + user?: JwtPayload; + tenantId?: string; +} + +// User types (matching auth.users table) +export interface User { + id: string; + tenant_id: string; + email: string; + password_hash?: string; + full_name: string; + status: 'active' | 'inactive' | 'pending' | 'suspended'; + is_superuser: boolean; + email_verified_at?: Date; + last_login_at?: Date; + created_at: Date; + updated_at: Date; +} + +// Role types (matching auth.roles table) +export interface Role { + id: string; + tenant_id: string; + name: string; + code: string; + description?: string; + is_system: boolean; + color?: string; + created_at: Date; +} + +// Permission types (matching auth.permissions table) +export interface Permission { + id: string; + resource: string; + action: string; + description?: string; + module: string; +} + +// Tenant types (matching auth.tenants table) +export interface Tenant { + id: string; + name: string; + subdomain: string; + schema_name: string; + status: 'active' | 'inactive' | 'suspended'; + settings: Record; + plan: string; + max_users: number; + created_at: Date; +} + +// Company types (matching auth.companies table) +export interface Company { + id: string; + tenant_id: string; + parent_company_id?: string; + name: string; + legal_name?: string; + tax_id?: string; + currency_id?: string; + settings: Record; + created_at: Date; +} + +// Error types +export class AppError extends Error { + constructor( + public message: string, + public statusCode: number = 500, + public code?: string + ) { + super(message); + this.name = 'AppError'; + Error.captureStackTrace(this, this.constructor); + } +} + +export class ValidationError extends AppError { + constructor(message: string, public details?: any[]) { + super(message, 400, 'VALIDATION_ERROR'); + this.name = 'ValidationError'; + } +} + +export class UnauthorizedError extends AppError { + constructor(message: string = 'No autorizado') { + super(message, 401, 'UNAUTHORIZED'); + this.name = 'UnauthorizedError'; + } +} + +export class ForbiddenError extends AppError { + constructor(message: string = 'Acceso denegado') { + super(message, 403, 'FORBIDDEN'); + this.name = 'ForbiddenError'; + } +} + +export class NotFoundError extends AppError { + constructor(message: string = 'Recurso no encontrado') { + super(message, 404, 'NOT_FOUND'); + this.name = 'NotFoundError'; + } +} diff --git a/src/shared/utils/logger.ts b/src/shared/utils/logger.ts new file mode 100644 index 0000000..e415c4e --- /dev/null +++ b/src/shared/utils/logger.ts @@ -0,0 +1,40 @@ +import winston from 'winston'; +import { config } from '../../config/index.js'; + +const { combine, timestamp, printf, colorize, errors } = winston.format; + +const logFormat = printf(({ level, message, timestamp, ...metadata }) => { + let msg = `${timestamp} [${level}]: ${message}`; + if (Object.keys(metadata).length > 0) { + msg += ` ${JSON.stringify(metadata)}`; + } + return msg; +}); + +export const logger = winston.createLogger({ + level: config.logging.level, + format: combine( + errors({ stack: true }), + timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + logFormat + ), + transports: [ + new winston.transports.Console({ + format: combine( + colorize(), + timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + logFormat + ), + }), + ], +}); + +// Add file transport in production +if (config.env === 'production') { + logger.add( + new winston.transports.File({ filename: 'logs/error.log', level: 'error' }) + ); + logger.add( + new winston.transports.File({ filename: 'logs/combined.log' }) + ); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..10327a5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "strictPropertyInitialization": false, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "baseUrl": "./src", + "paths": { + "@config/*": ["config/*"], + "@modules/*": ["modules/*"], + "@shared/*": ["shared/*"], + "@routes/*": ["routes/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +}