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.integration.ts b/src/app.integration.ts new file mode 100644 index 0000000..e48face --- /dev/null +++ b/src/app.integration.ts @@ -0,0 +1,503 @@ +/** + * Application Integration + * + * Integrates all modules and configures the application + */ + +import express, { Express, Router } from 'express'; +import { DataSource } from 'typeorm'; + +// Import modules +import { ProfilesModule } from './modules/profiles'; +import { BranchesModule } from './modules/branches'; +import { BillingUsageModule } from './modules/billing-usage'; +import { PaymentTerminalsModule } from './modules/payment-terminals'; + +// Import new business modules +import { PartnersModule } from './modules/partners'; +import { ProductsModule } from './modules/products'; +import { WarehousesModule } from './modules/warehouses'; +import { InventoryModule } from './modules/inventory'; +import { SalesModule } from './modules/sales'; +import { PurchasesModule } from './modules/purchases'; +import { InvoicesModule } from './modules/invoices'; +import { ReportsModule } from './modules/reports'; +import { DashboardModule } from './modules/dashboard'; + +// Import entities from all modules for TypeORM +import { + Person, + UserProfile, + ProfileTool, + ProfileModule, + UserProfileAssignment, +} from './modules/profiles/entities'; + +import { + Device, + BiometricCredential, + DeviceSession, + DeviceActivityLog, +} from './modules/biometrics/entities'; + +import { + Branch, + UserBranchAssignment, + BranchSchedule, + BranchPaymentTerminal, +} from './modules/branches/entities'; + +import { + MobileSession, + OfflineSyncQueue, + PushToken, + PaymentTransaction, +} from './modules/mobile/entities'; + +import { + SubscriptionPlan, + TenantSubscription, + UsageTracking, + Invoice as BillingInvoice, + InvoiceItem as BillingInvoiceItem, +} from './modules/billing-usage/entities'; + +// Import entities from new business modules +import { + Partner, + PartnerAddress, + PartnerContact, + PartnerBankAccount, +} from './modules/partners/entities'; + +import { + ProductCategory, + Product, + ProductPrice, + ProductSupplier, +} from './modules/products/entities'; + +import { + Warehouse, + WarehouseLocation, + WarehouseZone, +} from './modules/warehouses/entities'; + +import { + StockLevel, + StockMovement, + InventoryCount, + InventoryCountLine, + TransferOrder, + TransferOrderLine, +} from './modules/inventory/entities'; + +import { + Quotation, + QuotationItem, + SalesOrder, + SalesOrderItem, +} from './modules/sales/entities'; + +import { + PurchaseOrder, + PurchaseOrderItem, + PurchaseReceipt, + PurchaseReceiptItem, +} from './modules/purchases/entities'; + +import { + Invoice, + InvoiceItem, + Payment, + PaymentAllocation, +} from './modules/invoices/entities'; + +/** + * Get all entities for TypeORM configuration + */ +export function getAllEntities() { + return [ + // Profiles + Person, + UserProfile, + ProfileTool, + ProfileModule, + UserProfileAssignment, + // Biometrics + Device, + BiometricCredential, + DeviceSession, + DeviceActivityLog, + // Branches + Branch, + UserBranchAssignment, + BranchSchedule, + BranchPaymentTerminal, + // Mobile + MobileSession, + OfflineSyncQueue, + PushToken, + PaymentTransaction, + // Billing + SubscriptionPlan, + TenantSubscription, + UsageTracking, + BillingInvoice, + BillingInvoiceItem, + // Partners + Partner, + PartnerAddress, + PartnerContact, + PartnerBankAccount, + // Products + ProductCategory, + Product, + ProductPrice, + ProductSupplier, + // Warehouses + Warehouse, + WarehouseLocation, + WarehouseZone, + // Inventory + StockLevel, + StockMovement, + InventoryCount, + InventoryCountLine, + TransferOrder, + TransferOrderLine, + // Sales + Quotation, + QuotationItem, + SalesOrder, + SalesOrderItem, + // Purchases + PurchaseOrder, + PurchaseOrderItem, + PurchaseReceipt, + PurchaseReceiptItem, + // Invoices + Invoice, + InvoiceItem, + Payment, + PaymentAllocation, + ]; +} + +/** + * Module configuration options + */ +export interface ModuleOptions { + profiles?: { + enabled: boolean; + basePath?: string; + }; + branches?: { + enabled: boolean; + basePath?: string; + }; + billing?: { + enabled: boolean; + basePath?: string; + }; + payments?: { + enabled: boolean; + basePath?: string; + }; + partners?: { + enabled: boolean; + basePath?: string; + }; + products?: { + enabled: boolean; + basePath?: string; + }; + warehouses?: { + enabled: boolean; + basePath?: string; + }; + inventory?: { + enabled: boolean; + basePath?: string; + }; + sales?: { + enabled: boolean; + basePath?: string; + }; + purchases?: { + enabled: boolean; + basePath?: string; + }; + invoices?: { + enabled: boolean; + basePath?: string; + }; + reports?: { + enabled: boolean; + basePath?: string; + }; + dashboard?: { + enabled: boolean; + basePath?: string; + }; +} + +/** + * Default module options + */ +const defaultModuleOptions: ModuleOptions = { + profiles: { enabled: true, basePath: '/api' }, + branches: { enabled: true, basePath: '/api' }, + billing: { enabled: true, basePath: '/api' }, + payments: { enabled: true, basePath: '/api' }, + partners: { enabled: true, basePath: '/api' }, + products: { enabled: true, basePath: '/api' }, + warehouses: { enabled: true, basePath: '/api' }, + inventory: { enabled: true, basePath: '/api' }, + sales: { enabled: true, basePath: '/api' }, + purchases: { enabled: true, basePath: '/api' }, + invoices: { enabled: true, basePath: '/api' }, + reports: { enabled: true, basePath: '/api' }, + dashboard: { enabled: true, basePath: '/api' }, +}; + +/** + * Initialize and integrate all modules + */ +export function initializeModules( + app: Express, + dataSource: DataSource, + options: ModuleOptions = {} +): void { + const config = { ...defaultModuleOptions, ...options }; + + // Initialize Profiles Module + if (config.profiles?.enabled) { + const profilesModule = new ProfilesModule({ + dataSource, + basePath: config.profiles.basePath, + }); + app.use(profilesModule.router); + console.log('✅ Profiles module initialized'); + } + + // Initialize Branches Module + if (config.branches?.enabled) { + const branchesModule = new BranchesModule({ + dataSource, + basePath: config.branches.basePath, + }); + app.use(branchesModule.router); + console.log('✅ Branches module initialized'); + } + + // Initialize Billing Module + if (config.billing?.enabled) { + const billingModule = new BillingUsageModule({ + dataSource, + basePath: config.billing.basePath, + }); + app.use(billingModule.router); + console.log('✅ Billing module initialized'); + } + + // Initialize Payment Terminals Module + if (config.payments?.enabled) { + const paymentModule = new PaymentTerminalsModule({ + dataSource, + basePath: config.payments.basePath, + }); + app.use(paymentModule.router); + console.log('✅ Payment Terminals module initialized'); + } + + // Initialize Partners Module + if (config.partners?.enabled) { + const partnersModule = new PartnersModule({ + dataSource, + basePath: config.partners.basePath, + }); + app.use(partnersModule.router); + console.log('✅ Partners module initialized'); + } + + // Initialize Products Module + if (config.products?.enabled) { + const productsModule = new ProductsModule({ + dataSource, + basePath: config.products.basePath, + }); + app.use(productsModule.router); + console.log('✅ Products module initialized'); + } + + // Initialize Warehouses Module + if (config.warehouses?.enabled) { + const warehousesModule = new WarehousesModule({ + dataSource, + basePath: config.warehouses.basePath, + }); + app.use(warehousesModule.router); + console.log('✅ Warehouses module initialized'); + } + + // Initialize Inventory Module + if (config.inventory?.enabled) { + const inventoryModule = new InventoryModule({ + dataSource, + basePath: config.inventory.basePath, + }); + app.use(inventoryModule.router); + console.log('✅ Inventory module initialized'); + } + + // Initialize Sales Module + if (config.sales?.enabled) { + const salesModule = new SalesModule({ + dataSource, + basePath: config.sales.basePath, + }); + app.use(salesModule.router); + console.log('✅ Sales module initialized'); + } + + // Initialize Purchases Module + if (config.purchases?.enabled) { + const purchasesModule = new PurchasesModule({ + dataSource, + basePath: config.purchases.basePath, + }); + app.use(purchasesModule.router); + console.log('✅ Purchases module initialized'); + } + + // Initialize Invoices Module + if (config.invoices?.enabled) { + const invoicesModule = new InvoicesModule({ + dataSource, + basePath: config.invoices.basePath, + }); + app.use(invoicesModule.router); + console.log('✅ Invoices module initialized'); + } + + // Initialize Reports Module + if (config.reports?.enabled) { + const reportsModule = new ReportsModule({ + dataSource, + basePath: config.reports.basePath, + }); + app.use(reportsModule.router); + console.log('✅ Reports module initialized'); + } + + // Initialize Dashboard Module + if (config.dashboard?.enabled) { + const dashboardModule = new DashboardModule({ + dataSource, + basePath: config.dashboard.basePath, + }); + app.use(dashboardModule.router); + console.log('✅ Dashboard module initialized'); + } +} + +/** + * Create TypeORM DataSource configuration + */ +export function createDataSourceConfig(options: { + host: string; + port: number; + username: string; + password: string; + database: string; + ssl?: boolean; + logging?: boolean; +}) { + return { + type: 'postgres' as const, + host: options.host, + port: options.port, + username: options.username, + password: options.password, + database: options.database, + ssl: options.ssl ? { rejectUnauthorized: false } : false, + logging: options.logging ?? false, + entities: getAllEntities(), + synchronize: false, // Use migrations instead + migrations: ['src/migrations/*.ts'], + }; +} + +/** + * Example application setup + */ +export async function createApplication(dataSourceConfig: any): Promise { + // Create Express app + const app = express(); + + // Middleware + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + + // CORS middleware (configure for production) + app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', '*'); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Tenant-ID'); + if (req.method === 'OPTIONS') { + res.sendStatus(200); + return; + } + next(); + }); + + // Initialize database + const dataSource = new DataSource(dataSourceConfig); + await dataSource.initialize(); + console.log('✅ Database connected'); + + // Initialize all modules + initializeModules(app, dataSource); + + // Health check endpoint + app.get('/health', (req, res) => { + res.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + modules: { + profiles: true, + branches: true, + billing: true, + payments: true, + partners: true, + products: true, + warehouses: true, + inventory: true, + sales: true, + purchases: true, + invoices: true, + reports: true, + dashboard: true, + }, + }); + }); + + // Error handling middleware + app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { + console.error('Error:', err); + res.status(err.status || 500).json({ + error: err.message || 'Internal Server Error', + code: err.code || 'INTERNAL_ERROR', + }); + }); + + return app; +} + +export default { + getAllEntities, + initializeModules, + createDataSourceConfig, + createApplication, +}; 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/ai/ai.module.ts b/src/modules/ai/ai.module.ts new file mode 100644 index 0000000..c7083dd --- /dev/null +++ b/src/modules/ai/ai.module.ts @@ -0,0 +1,66 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { AIService } from './services'; +import { AIController } from './controllers'; +import { + AIModel, + AIPrompt, + AIConversation, + AIMessage, + AIUsageLog, + AITenantQuota, +} from './entities'; + +export interface AIModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class AIModule { + public router: Router; + public aiService: AIService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: AIModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const modelRepository = this.dataSource.getRepository(AIModel); + const conversationRepository = this.dataSource.getRepository(AIConversation); + const messageRepository = this.dataSource.getRepository(AIMessage); + const promptRepository = this.dataSource.getRepository(AIPrompt); + const usageLogRepository = this.dataSource.getRepository(AIUsageLog); + const quotaRepository = this.dataSource.getRepository(AITenantQuota); + + this.aiService = new AIService( + modelRepository, + conversationRepository, + messageRepository, + promptRepository, + usageLogRepository, + quotaRepository + ); + } + + private initializeRoutes(): void { + const aiController = new AIController(this.aiService); + this.router.use(`${this.basePath}/ai`, aiController.router); + } + + static getEntities(): Function[] { + return [ + AIModel, + AIPrompt, + AIConversation, + AIMessage, + AIUsageLog, + AITenantQuota, + ]; + } +} diff --git a/src/modules/ai/controllers/ai.controller.ts b/src/modules/ai/controllers/ai.controller.ts new file mode 100644 index 0000000..3d126cf --- /dev/null +++ b/src/modules/ai/controllers/ai.controller.ts @@ -0,0 +1,381 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { AIService, ConversationFilters } from '../services/ai.service'; + +export class AIController { + public router: Router; + + constructor(private readonly aiService: AIService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Models + this.router.get('/models', this.findAllModels.bind(this)); + this.router.get('/models/:id', this.findModel.bind(this)); + this.router.get('/models/code/:code', this.findModelByCode.bind(this)); + this.router.get('/models/provider/:provider', this.findModelsByProvider.bind(this)); + this.router.get('/models/type/:type', this.findModelsByType.bind(this)); + + // Prompts + this.router.get('/prompts', this.findAllPrompts.bind(this)); + this.router.get('/prompts/:id', this.findPrompt.bind(this)); + this.router.get('/prompts/code/:code', this.findPromptByCode.bind(this)); + this.router.post('/prompts', this.createPrompt.bind(this)); + this.router.patch('/prompts/:id', this.updatePrompt.bind(this)); + + // Conversations + this.router.get('/conversations', this.findConversations.bind(this)); + this.router.get('/conversations/user/:userId', this.findUserConversations.bind(this)); + this.router.get('/conversations/:id', this.findConversation.bind(this)); + this.router.post('/conversations', this.createConversation.bind(this)); + this.router.patch('/conversations/:id', this.updateConversation.bind(this)); + this.router.post('/conversations/:id/archive', this.archiveConversation.bind(this)); + + // Messages + this.router.get('/conversations/:conversationId/messages', this.findMessages.bind(this)); + this.router.post('/conversations/:conversationId/messages', this.addMessage.bind(this)); + this.router.get('/conversations/:conversationId/tokens', this.getConversationTokenCount.bind(this)); + + // Usage & Quotas + this.router.post('/usage', this.logUsage.bind(this)); + this.router.get('/usage/stats', this.getUsageStats.bind(this)); + this.router.get('/quotas', this.getTenantQuota.bind(this)); + this.router.patch('/quotas', this.updateTenantQuota.bind(this)); + this.router.get('/quotas/check', this.checkQuotaAvailable.bind(this)); + } + + // ============================================ + // MODELS + // ============================================ + + private async findAllModels(req: Request, res: Response, next: NextFunction): Promise { + try { + const models = await this.aiService.findAllModels(); + res.json({ data: models, total: models.length }); + } catch (error) { + next(error); + } + } + + private async findModel(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const model = await this.aiService.findModel(id); + + if (!model) { + res.status(404).json({ error: 'Model not found' }); + return; + } + + res.json({ data: model }); + } catch (error) { + next(error); + } + } + + private async findModelByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const model = await this.aiService.findModelByCode(code); + + if (!model) { + res.status(404).json({ error: 'Model not found' }); + return; + } + + res.json({ data: model }); + } catch (error) { + next(error); + } + } + + private async findModelsByProvider(req: Request, res: Response, next: NextFunction): Promise { + try { + const { provider } = req.params; + const models = await this.aiService.findModelsByProvider(provider); + res.json({ data: models, total: models.length }); + } catch (error) { + next(error); + } + } + + private async findModelsByType(req: Request, res: Response, next: NextFunction): Promise { + try { + const { type } = req.params; + const models = await this.aiService.findModelsByType(type); + res.json({ data: models, total: models.length }); + } catch (error) { + next(error); + } + } + + // ============================================ + // PROMPTS + // ============================================ + + private async findAllPrompts(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const prompts = await this.aiService.findAllPrompts(tenantId); + res.json({ data: prompts, total: prompts.length }); + } catch (error) { + next(error); + } + } + + private async findPrompt(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const prompt = await this.aiService.findPrompt(id); + + if (!prompt) { + res.status(404).json({ error: 'Prompt not found' }); + return; + } + + res.json({ data: prompt }); + } catch (error) { + next(error); + } + } + + private async findPromptByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const tenantId = req.headers['x-tenant-id'] as string; + const prompt = await this.aiService.findPromptByCode(code, tenantId); + + if (!prompt) { + res.status(404).json({ error: 'Prompt not found' }); + return; + } + + // Increment usage count + await this.aiService.incrementPromptUsage(prompt.id); + + res.json({ data: prompt }); + } catch (error) { + next(error); + } + } + + private async createPrompt(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const prompt = await this.aiService.createPrompt(tenantId, req.body, userId); + res.status(201).json({ data: prompt }); + } catch (error) { + next(error); + } + } + + private async updatePrompt(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const userId = req.headers['x-user-id'] as string; + + const prompt = await this.aiService.updatePrompt(id, req.body, userId); + + if (!prompt) { + res.status(404).json({ error: 'Prompt not found' }); + return; + } + + res.json({ data: prompt }); + } catch (error) { + next(error); + } + } + + // ============================================ + // CONVERSATIONS + // ============================================ + + private async findConversations(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const filters: ConversationFilters = { + userId: req.query.userId as string, + modelId: req.query.modelId as string, + status: req.query.status as string, + }; + + if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string); + if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string); + + const limit = parseInt(req.query.limit as string) || 50; + + const conversations = await this.aiService.findConversations(tenantId, filters, limit); + res.json({ data: conversations, total: conversations.length }); + } catch (error) { + next(error); + } + } + + private async findUserConversations(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { userId } = req.params; + const limit = parseInt(req.query.limit as string) || 20; + + const conversations = await this.aiService.findUserConversations(tenantId, userId, limit); + res.json({ data: conversations, total: conversations.length }); + } catch (error) { + next(error); + } + } + + private async findConversation(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const conversation = await this.aiService.findConversation(id); + + if (!conversation) { + res.status(404).json({ error: 'Conversation not found' }); + return; + } + + res.json({ data: conversation }); + } catch (error) { + next(error); + } + } + + private async createConversation(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const conversation = await this.aiService.createConversation(tenantId, userId, req.body); + res.status(201).json({ data: conversation }); + } catch (error) { + next(error); + } + } + + private async updateConversation(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const conversation = await this.aiService.updateConversation(id, req.body); + + if (!conversation) { + res.status(404).json({ error: 'Conversation not found' }); + return; + } + + res.json({ data: conversation }); + } catch (error) { + next(error); + } + } + + private async archiveConversation(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const archived = await this.aiService.archiveConversation(id); + + if (!archived) { + res.status(404).json({ error: 'Conversation not found' }); + return; + } + + res.json({ data: { success: true } }); + } catch (error) { + next(error); + } + } + + // ============================================ + // MESSAGES + // ============================================ + + private async findMessages(req: Request, res: Response, next: NextFunction): Promise { + try { + const { conversationId } = req.params; + const messages = await this.aiService.findMessages(conversationId); + res.json({ data: messages, total: messages.length }); + } catch (error) { + next(error); + } + } + + private async addMessage(req: Request, res: Response, next: NextFunction): Promise { + try { + const { conversationId } = req.params; + const message = await this.aiService.addMessage(conversationId, req.body); + res.status(201).json({ data: message }); + } catch (error) { + next(error); + } + } + + private async getConversationTokenCount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { conversationId } = req.params; + const tokenCount = await this.aiService.getConversationTokenCount(conversationId); + res.json({ data: { tokenCount } }); + } catch (error) { + next(error); + } + } + + // ============================================ + // USAGE & QUOTAS + // ============================================ + + private async logUsage(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const log = await this.aiService.logUsage(tenantId, req.body); + res.status(201).json({ data: log }); + } catch (error) { + next(error); + } + } + + private async getUsageStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const startDate = new Date(req.query.startDate as string || Date.now() - 30 * 24 * 60 * 60 * 1000); + const endDate = new Date(req.query.endDate as string || Date.now()); + + const stats = await this.aiService.getUsageStats(tenantId, startDate, endDate); + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + private async getTenantQuota(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const quota = await this.aiService.getTenantQuota(tenantId); + res.json({ data: quota }); + } catch (error) { + next(error); + } + } + + private async updateTenantQuota(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const quota = await this.aiService.updateTenantQuota(tenantId, req.body); + res.json({ data: quota }); + } catch (error) { + next(error); + } + } + + private async checkQuotaAvailable(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const result = await this.aiService.checkQuotaAvailable(tenantId); + res.json({ data: result }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/ai/controllers/index.ts b/src/modules/ai/controllers/index.ts new file mode 100644 index 0000000..cf85729 --- /dev/null +++ b/src/modules/ai/controllers/index.ts @@ -0,0 +1 @@ +export { AIController } from './ai.controller'; diff --git a/src/modules/ai/dto/ai.dto.ts b/src/modules/ai/dto/ai.dto.ts new file mode 100644 index 0000000..39daa77 --- /dev/null +++ b/src/modules/ai/dto/ai.dto.ts @@ -0,0 +1,343 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsObject, + IsUUID, + MaxLength, + MinLength, + Min, + Max, +} from 'class-validator'; + +// ============================================ +// PROMPT DTOs +// ============================================ + +export class CreatePromptDto { + @IsString() + @MinLength(2) + @MaxLength(50) + code: string; + + @IsString() + @MinLength(2) + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + category?: string; + + @IsString() + systemPrompt: string; + + @IsOptional() + @IsString() + userPromptTemplate?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + variables?: string[]; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(2) + temperature?: number; + + @IsOptional() + @IsNumber() + @Min(1) + maxTokens?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + stopSequences?: string[]; + + @IsOptional() + @IsObject() + modelParameters?: Record; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedModels?: string[]; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class UpdatePromptDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + category?: string; + + @IsOptional() + @IsString() + systemPrompt?: string; + + @IsOptional() + @IsString() + userPromptTemplate?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + variables?: string[]; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(2) + temperature?: number; + + @IsOptional() + @IsNumber() + @Min(1) + maxTokens?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + stopSequences?: string[]; + + @IsOptional() + @IsObject() + modelParameters?: Record; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +// ============================================ +// CONVERSATION DTOs +// ============================================ + +export class CreateConversationDto { + @IsOptional() + @IsUUID() + modelId?: string; + + @IsOptional() + @IsUUID() + promptId?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + title?: string; + + @IsOptional() + @IsString() + systemPrompt?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(2) + temperature?: number; + + @IsOptional() + @IsNumber() + @Min(1) + maxTokens?: number; + + @IsOptional() + @IsObject() + context?: Record; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class UpdateConversationDto { + @IsOptional() + @IsString() + @MaxLength(200) + title?: string; + + @IsOptional() + @IsString() + systemPrompt?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(2) + temperature?: number; + + @IsOptional() + @IsNumber() + @Min(1) + maxTokens?: number; + + @IsOptional() + @IsObject() + context?: Record; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +// ============================================ +// MESSAGE DTOs +// ============================================ + +export class AddMessageDto { + @IsString() + @MaxLength(20) + role: string; + + @IsString() + content: string; + + @IsOptional() + @IsString() + @MaxLength(50) + modelCode?: string; + + @IsOptional() + @IsNumber() + @Min(0) + promptTokens?: number; + + @IsOptional() + @IsNumber() + @Min(0) + completionTokens?: number; + + @IsOptional() + @IsNumber() + @Min(0) + totalTokens?: number; + + @IsOptional() + @IsString() + @MaxLength(30) + finishReason?: string; + + @IsOptional() + @IsNumber() + @Min(0) + latencyMs?: number; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +// ============================================ +// USAGE DTOs +// ============================================ + +export class LogUsageDto { + @IsOptional() + @IsUUID() + userId?: string; + + @IsOptional() + @IsUUID() + conversationId?: string; + + @IsUUID() + modelId: string; + + @IsString() + @MaxLength(20) + usageType: string; + + @IsNumber() + @Min(0) + inputTokens: number; + + @IsNumber() + @Min(0) + outputTokens: number; + + @IsOptional() + @IsNumber() + @Min(0) + costUsd?: number; + + @IsOptional() + @IsNumber() + @Min(0) + latencyMs?: number; + + @IsOptional() + @IsBoolean() + wasSuccessful?: boolean; + + @IsOptional() + @IsString() + errorMessage?: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +// ============================================ +// QUOTA DTOs +// ============================================ + +export class UpdateQuotaDto { + @IsOptional() + @IsNumber() + @Min(0) + maxRequestsPerMonth?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxTokensPerMonth?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxSpendPerMonth?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxRequestsPerDay?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxTokensPerDay?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedModels?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + blockedModels?: string[]; +} diff --git a/src/modules/ai/dto/index.ts b/src/modules/ai/dto/index.ts new file mode 100644 index 0000000..65584c6 --- /dev/null +++ b/src/modules/ai/dto/index.ts @@ -0,0 +1,9 @@ +export { + CreatePromptDto, + UpdatePromptDto, + CreateConversationDto, + UpdateConversationDto, + AddMessageDto, + LogUsageDto, + UpdateQuotaDto, +} from './ai.dto'; diff --git a/src/modules/ai/entities/completion.entity.ts b/src/modules/ai/entities/completion.entity.ts new file mode 100644 index 0000000..6c0e712 --- /dev/null +++ b/src/modules/ai/entities/completion.entity.ts @@ -0,0 +1,92 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { AIModel } from './model.entity'; +import { AIPrompt } from './prompt.entity'; + +export type CompletionStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +@Entity({ name: 'completions', schema: 'ai' }) +export class AICompletion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Index() + @Column({ name: 'prompt_id', type: 'uuid', nullable: true }) + promptId: string; + + @Column({ name: 'prompt_code', type: 'varchar', length: 100, nullable: true }) + promptCode: string; + + @Column({ name: 'model_id', type: 'uuid', nullable: true }) + modelId: string; + + @Column({ name: 'input_text', type: 'text' }) + inputText: string; + + @Column({ name: 'input_variables', type: 'jsonb', default: {} }) + inputVariables: Record; + + @Column({ name: 'output_text', type: 'text', nullable: true }) + outputText: string; + + @Column({ name: 'prompt_tokens', type: 'int', nullable: true }) + promptTokens: number; + + @Column({ name: 'completion_tokens', type: 'int', nullable: true }) + completionTokens: number; + + @Column({ name: 'total_tokens', type: 'int', nullable: true }) + totalTokens: number; + + @Column({ name: 'cost', type: 'decimal', precision: 10, scale: 6, nullable: true }) + cost: number; + + @Column({ name: 'latency_ms', type: 'int', nullable: true }) + latencyMs: number; + + @Column({ name: 'finish_reason', type: 'varchar', length: 30, nullable: true }) + finishReason: string; + + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: CompletionStatus; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Index() + @Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true }) + contextType: string; + + @Column({ name: 'context_id', type: 'uuid', nullable: true }) + contextId: string; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => AIModel, { nullable: true }) + @JoinColumn({ name: 'model_id' }) + model: AIModel; + + @ManyToOne(() => AIPrompt, { nullable: true }) + @JoinColumn({ name: 'prompt_id' }) + prompt: AIPrompt; +} diff --git a/src/modules/ai/entities/conversation.entity.ts b/src/modules/ai/entities/conversation.entity.ts new file mode 100644 index 0000000..636d2a8 --- /dev/null +++ b/src/modules/ai/entities/conversation.entity.ts @@ -0,0 +1,160 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, +} from 'typeorm'; +import { AIModel } from './model.entity'; + +export type ConversationStatus = 'active' | 'archived' | 'deleted'; +export type MessageRole = 'system' | 'user' | 'assistant' | 'function'; +export type FinishReason = 'stop' | 'length' | 'function_call' | 'content_filter'; + +@Entity({ name: 'conversations', schema: 'ai' }) +export class AIConversation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'title', type: 'varchar', length: 255, nullable: true }) + title: string; + + @Column({ name: 'summary', type: 'text', nullable: true }) + summary: string; + + @Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true }) + contextType: string; + + @Column({ name: 'context_data', type: 'jsonb', default: {} }) + contextData: Record; + + @Column({ name: 'model_id', type: 'uuid', nullable: true }) + modelId: string; + + @Column({ name: 'prompt_id', type: 'uuid', nullable: true }) + promptId: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'active' }) + status: ConversationStatus; + + @Column({ name: 'is_pinned', type: 'boolean', default: false }) + isPinned: boolean; + + @Column({ name: 'message_count', type: 'int', default: 0 }) + messageCount: number; + + @Column({ name: 'total_tokens', type: 'int', default: 0 }) + totalTokens: number; + + @Column({ name: 'total_cost', type: 'decimal', precision: 10, scale: 4, default: 0 }) + totalCost: number; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'last_message_at', type: 'timestamptz', nullable: true }) + lastMessageAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => AIModel, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'model_id' }) + model: AIModel; + + @OneToMany(() => AIMessage, (message) => message.conversation) + messages: AIMessage[]; +} + +@Entity({ name: 'messages', schema: 'ai' }) +export class AIMessage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'conversation_id', type: 'uuid' }) + conversationId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'role', type: 'varchar', length: 20 }) + role: MessageRole; + + @Column({ name: 'content', type: 'text' }) + content: string; + + @Column({ name: 'function_name', type: 'varchar', length: 100, nullable: true }) + functionName: string; + + @Column({ name: 'function_arguments', type: 'jsonb', nullable: true }) + functionArguments: Record; + + @Column({ name: 'function_result', type: 'jsonb', nullable: true }) + functionResult: Record; + + @Column({ name: 'model_id', type: 'uuid', nullable: true }) + modelId: string; + + @Column({ name: 'model_response_id', type: 'varchar', length: 255, nullable: true }) + modelResponseId: string; + + @Column({ name: 'prompt_tokens', type: 'int', nullable: true }) + promptTokens: number; + + @Column({ name: 'completion_tokens', type: 'int', nullable: true }) + completionTokens: number; + + @Column({ name: 'total_tokens', type: 'int', nullable: true }) + totalTokens: number; + + @Column({ name: 'cost', type: 'decimal', precision: 10, scale: 6, nullable: true }) + cost: number; + + @Column({ name: 'latency_ms', type: 'int', nullable: true }) + latencyMs: number; + + @Column({ name: 'finish_reason', type: 'varchar', length: 30, nullable: true }) + finishReason: FinishReason; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'feedback_rating', type: 'int', nullable: true }) + feedbackRating: number; + + @Column({ name: 'feedback_text', type: 'text', nullable: true }) + feedbackText: string; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => AIConversation, (conversation) => conversation.messages, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'conversation_id' }) + conversation: AIConversation; + + @ManyToOne(() => AIModel, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'model_id' }) + model: AIModel; +} diff --git a/src/modules/ai/entities/embedding.entity.ts b/src/modules/ai/entities/embedding.entity.ts new file mode 100644 index 0000000..4d30c99 --- /dev/null +++ b/src/modules/ai/entities/embedding.entity.ts @@ -0,0 +1,77 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { AIModel } from './model.entity'; + +@Entity({ name: 'embeddings', schema: 'ai' }) +export class AIEmbedding { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'content', type: 'text' }) + content: string; + + @Index() + @Column({ name: 'content_hash', type: 'varchar', length: 64, nullable: true }) + contentHash: string; + + // Note: If pgvector is enabled, use 'vector' type instead of 'jsonb' + @Column({ name: 'embedding_json', type: 'jsonb', nullable: true }) + embeddingJson: number[]; + + @Column({ name: 'model_id', type: 'uuid', nullable: true }) + modelId: string; + + @Column({ name: 'model_name', type: 'varchar', length: 100, nullable: true }) + modelName: string; + + @Column({ name: 'dimensions', type: 'int', nullable: true }) + dimensions: number; + + @Index() + @Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true }) + entityType: string; + + @Column({ name: 'entity_id', type: 'uuid', nullable: true }) + entityId: string; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'chunk_index', type: 'int', nullable: true }) + chunkIndex: number; + + @Column({ name: 'chunk_total', type: 'int', nullable: true }) + chunkTotal: number; + + @Column({ name: 'parent_embedding_id', type: 'uuid', nullable: true }) + parentEmbeddingId: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => AIModel, { nullable: true }) + @JoinColumn({ name: 'model_id' }) + model: AIModel; + + @ManyToOne(() => AIEmbedding, { nullable: true }) + @JoinColumn({ name: 'parent_embedding_id' }) + parentEmbedding: AIEmbedding; +} diff --git a/src/modules/ai/entities/index.ts b/src/modules/ai/entities/index.ts new file mode 100644 index 0000000..8317b21 --- /dev/null +++ b/src/modules/ai/entities/index.ts @@ -0,0 +1,7 @@ +export { AIModel, AIProvider, ModelType } from './model.entity'; +export { AIConversation, AIMessage, ConversationStatus, MessageRole, FinishReason } from './conversation.entity'; +export { AIPrompt, PromptCategory } from './prompt.entity'; +export { AIUsageLog, AITenantQuota, UsageType } from './usage.entity'; +export { AICompletion, CompletionStatus } from './completion.entity'; +export { AIEmbedding } from './embedding.entity'; +export { AIKnowledgeBase, KnowledgeSourceType, KnowledgeContentType } from './knowledge-base.entity'; diff --git a/src/modules/ai/entities/knowledge-base.entity.ts b/src/modules/ai/entities/knowledge-base.entity.ts new file mode 100644 index 0000000..55e65ec --- /dev/null +++ b/src/modules/ai/entities/knowledge-base.entity.ts @@ -0,0 +1,98 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { AIEmbedding } from './embedding.entity'; + +export type KnowledgeSourceType = 'manual' | 'document' | 'website' | 'api'; +export type KnowledgeContentType = 'faq' | 'documentation' | 'policy' | 'procedure'; + +@Entity({ name: 'knowledge_base', schema: 'ai' }) +@Unique(['tenantId', 'code']) +export class AIKnowledgeBase { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Column({ name: 'code', type: 'varchar', length: 100 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'source_type', type: 'varchar', length: 30, nullable: true }) + sourceType: KnowledgeSourceType; + + @Column({ name: 'source_url', type: 'text', nullable: true }) + sourceUrl: string; + + @Column({ name: 'source_file_id', type: 'uuid', nullable: true }) + sourceFileId: string; + + @Column({ name: 'content', type: 'text' }) + content: string; + + @Column({ name: 'content_type', type: 'varchar', length: 50, nullable: true }) + contentType: KnowledgeContentType; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 100, nullable: true }) + category: string; + + @Column({ name: 'subcategory', type: 'varchar', length: 100, nullable: true }) + subcategory: string; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'embedding_id', type: 'uuid', nullable: true }) + embeddingId: string; + + @Column({ name: 'priority', type: 'int', default: 0 }) + priority: number; + + @Column({ name: 'relevance_score', type: 'decimal', precision: 5, scale: 4, nullable: true }) + relevanceScore: number; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ name: 'verified_by', type: 'uuid', nullable: true }) + verifiedBy: string; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => AIEmbedding, { nullable: true }) + @JoinColumn({ name: 'embedding_id' }) + embedding: AIEmbedding; +} diff --git a/src/modules/ai/entities/model.entity.ts b/src/modules/ai/entities/model.entity.ts new file mode 100644 index 0000000..893ea83 --- /dev/null +++ b/src/modules/ai/entities/model.entity.ts @@ -0,0 +1,78 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type AIProvider = 'openai' | 'anthropic' | 'google' | 'azure' | 'local'; +export type ModelType = 'chat' | 'completion' | 'embedding' | 'image' | 'audio'; + +@Entity({ name: 'models', schema: 'ai' }) +export class AIModel { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index({ unique: true }) + @Column({ name: 'code', type: 'varchar', length: 100 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'provider', type: 'varchar', length: 50 }) + provider: AIProvider; + + @Column({ name: 'model_id', type: 'varchar', length: 100 }) + modelId: string; + + @Index() + @Column({ name: 'model_type', type: 'varchar', length: 30 }) + modelType: ModelType; + + @Column({ name: 'max_tokens', type: 'int', nullable: true }) + maxTokens: number; + + @Column({ name: 'supports_functions', type: 'boolean', default: false }) + supportsFunctions: boolean; + + @Column({ name: 'supports_vision', type: 'boolean', default: false }) + supportsVision: boolean; + + @Column({ name: 'supports_streaming', type: 'boolean', default: true }) + supportsStreaming: boolean; + + @Column({ name: 'input_cost_per_1k', type: 'decimal', precision: 10, scale: 6, nullable: true }) + inputCostPer1k: number; + + @Column({ name: 'output_cost_per_1k', type: 'decimal', precision: 10, scale: 6, nullable: true }) + outputCostPer1k: number; + + @Column({ name: 'rate_limit_rpm', type: 'int', nullable: true }) + rateLimitRpm: number; + + @Column({ name: 'rate_limit_tpm', type: 'int', nullable: true }) + rateLimitTpm: number; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/ai/entities/prompt.entity.ts b/src/modules/ai/entities/prompt.entity.ts new file mode 100644 index 0000000..dfbaf57 --- /dev/null +++ b/src/modules/ai/entities/prompt.entity.ts @@ -0,0 +1,110 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { AIModel } from './model.entity'; + +export type PromptCategory = 'assistant' | 'analysis' | 'generation' | 'extraction'; + +@Entity({ name: 'prompts', schema: 'ai' }) +@Unique(['tenantId', 'code', 'version']) +export class AIPrompt { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Index() + @Column({ name: 'code', type: 'varchar', length: 100 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: PromptCategory; + + @Column({ name: 'system_prompt', type: 'text', nullable: true }) + systemPrompt: string; + + @Column({ name: 'user_prompt_template', type: 'text' }) + userPromptTemplate: string; + + @Column({ name: 'model_id', type: 'uuid', nullable: true }) + modelId: string; + + @Column({ name: 'temperature', type: 'decimal', precision: 3, scale: 2, default: 0.7 }) + temperature: number; + + @Column({ name: 'max_tokens', type: 'int', nullable: true }) + maxTokens: number; + + @Column({ name: 'top_p', type: 'decimal', precision: 3, scale: 2, nullable: true }) + topP: number; + + @Column({ name: 'frequency_penalty', type: 'decimal', precision: 3, scale: 2, nullable: true }) + frequencyPenalty: number; + + @Column({ name: 'presence_penalty', type: 'decimal', precision: 3, scale: 2, nullable: true }) + presencePenalty: number; + + @Column({ name: 'required_variables', type: 'text', array: true, default: [] }) + requiredVariables: string[]; + + @Column({ name: 'variable_schema', type: 'jsonb', default: {} }) + variableSchema: Record; + + @Column({ name: 'functions', type: 'jsonb', default: [] }) + functions: Record[]; + + @Column({ name: 'version', type: 'int', default: 1 }) + version: number; + + @Column({ name: 'is_latest', type: 'boolean', default: true }) + isLatest: boolean; + + @Column({ name: 'parent_version_id', type: 'uuid', nullable: true }) + parentVersionId: string; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem: boolean; + + @Column({ name: 'usage_count', type: 'int', default: 0 }) + usageCount: number; + + @Column({ name: 'avg_tokens_used', type: 'int', nullable: true }) + avgTokensUsed: number; + + @Column({ name: 'avg_latency_ms', type: 'int', nullable: true }) + avgLatencyMs: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => AIModel, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'model_id' }) + model: AIModel; +} diff --git a/src/modules/ai/entities/usage.entity.ts b/src/modules/ai/entities/usage.entity.ts new file mode 100644 index 0000000..42eaf3d --- /dev/null +++ b/src/modules/ai/entities/usage.entity.ts @@ -0,0 +1,120 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type UsageType = 'chat' | 'completion' | 'embedding' | 'image'; + +@Entity({ name: 'usage_logs', schema: 'ai' }) +export class AIUsageLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Index() + @Column({ name: 'model_id', type: 'uuid', nullable: true }) + modelId: string; + + @Column({ name: 'model_name', type: 'varchar', length: 100, nullable: true }) + modelName: string; + + @Column({ name: 'provider', type: 'varchar', length: 50, nullable: true }) + provider: string; + + @Column({ name: 'usage_type', type: 'varchar', length: 30 }) + usageType: UsageType; + + @Column({ name: 'prompt_tokens', type: 'int', default: 0 }) + promptTokens: number; + + @Column({ name: 'completion_tokens', type: 'int', default: 0 }) + completionTokens: number; + + @Column({ name: 'total_tokens', type: 'int', default: 0 }) + totalTokens: number; + + @Column({ name: 'cost', type: 'decimal', precision: 10, scale: 6, default: 0 }) + cost: number; + + @Column({ name: 'conversation_id', type: 'uuid', nullable: true }) + conversationId: string; + + @Column({ name: 'completion_id', type: 'uuid', nullable: true }) + completionId: string; + + @Column({ name: 'request_id', type: 'varchar', length: 255, nullable: true }) + requestId: string; + + @Index() + @Column({ name: 'usage_date', type: 'date', default: () => 'CURRENT_DATE' }) + usageDate: Date; + + @Index() + @Column({ name: 'usage_month', type: 'varchar', length: 7, nullable: true }) + usageMonth: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} + +@Entity({ name: 'tenant_quotas', schema: 'ai' }) +@Unique(['tenantId', 'quotaMonth']) +export class AITenantQuota { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'monthly_token_limit', type: 'int', nullable: true }) + monthlyTokenLimit: number; + + @Column({ name: 'monthly_request_limit', type: 'int', nullable: true }) + monthlyRequestLimit: number; + + @Column({ name: 'monthly_cost_limit', type: 'decimal', precision: 10, scale: 2, nullable: true }) + monthlyCostLimit: number; + + @Column({ name: 'current_tokens', type: 'int', default: 0 }) + currentTokens: number; + + @Column({ name: 'current_requests', type: 'int', default: 0 }) + currentRequests: number; + + @Column({ name: 'current_cost', type: 'decimal', precision: 10, scale: 4, default: 0 }) + currentCost: number; + + @Index() + @Column({ name: 'quota_month', type: 'varchar', length: 7 }) + quotaMonth: string; + + @Column({ name: 'is_exceeded', type: 'boolean', default: false }) + isExceeded: boolean; + + @Column({ name: 'exceeded_at', type: 'timestamptz', nullable: true }) + exceededAt: Date; + + @Column({ name: 'alert_threshold_percent', type: 'int', default: 80 }) + alertThresholdPercent: number; + + @Column({ name: 'alert_sent_at', type: 'timestamptz', nullable: true }) + alertSentAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/ai/index.ts b/src/modules/ai/index.ts new file mode 100644 index 0000000..b2ce0ae --- /dev/null +++ b/src/modules/ai/index.ts @@ -0,0 +1,5 @@ +export { AIModule, AIModuleOptions } from './ai.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/ai/services/ai.service.ts b/src/modules/ai/services/ai.service.ts new file mode 100644 index 0000000..cbc626c --- /dev/null +++ b/src/modules/ai/services/ai.service.ts @@ -0,0 +1,384 @@ +import { Repository, FindOptionsWhere, LessThan, MoreThanOrEqual } from 'typeorm'; +import { AIModel, AIConversation, AIMessage, AIPrompt, AIUsageLog, AITenantQuota } from '../entities'; + +export interface ConversationFilters { + userId?: string; + modelId?: string; + status?: string; + startDate?: Date; + endDate?: Date; +} + +export class AIService { + constructor( + private readonly modelRepository: Repository, + private readonly conversationRepository: Repository, + private readonly messageRepository: Repository, + private readonly promptRepository: Repository, + private readonly usageLogRepository: Repository, + private readonly quotaRepository: Repository + ) {} + + // ============================================ + // MODELS + // ============================================ + + async findAllModels(): Promise { + return this.modelRepository.find({ + where: { isActive: true }, + order: { provider: 'ASC', name: 'ASC' }, + }); + } + + async findModel(id: string): Promise { + return this.modelRepository.findOne({ where: { id } }); + } + + async findModelByCode(code: string): Promise { + return this.modelRepository.findOne({ where: { code } }); + } + + async findModelsByProvider(provider: string): Promise { + return this.modelRepository.find({ + where: { provider: provider as any, isActive: true }, + order: { name: 'ASC' }, + }); + } + + async findModelsByType(modelType: string): Promise { + return this.modelRepository.find({ + where: { modelType: modelType as any, isActive: true }, + order: { name: 'ASC' }, + }); + } + + // ============================================ + // PROMPTS + // ============================================ + + async findAllPrompts(tenantId?: string): Promise { + if (tenantId) { + return this.promptRepository.find({ + where: [{ tenantId, isActive: true }, { isSystem: true, isActive: true }], + order: { category: 'ASC', name: 'ASC' }, + }); + } + return this.promptRepository.find({ + where: { isActive: true }, + order: { category: 'ASC', name: 'ASC' }, + }); + } + + async findPrompt(id: string): Promise { + return this.promptRepository.findOne({ where: { id } }); + } + + async findPromptByCode(code: string, tenantId?: string): Promise { + if (tenantId) { + // Try tenant-specific first, then system prompt + const tenantPrompt = await this.promptRepository.findOne({ + where: { code, tenantId, isActive: true }, + }); + if (tenantPrompt) return tenantPrompt; + + return this.promptRepository.findOne({ + where: { code, isSystem: true, isActive: true }, + }); + } + return this.promptRepository.findOne({ where: { code, isActive: true } }); + } + + async createPrompt( + tenantId: string, + data: Partial, + createdBy?: string + ): Promise { + const prompt = this.promptRepository.create({ + ...data, + tenantId, + createdBy, + version: 1, + }); + return this.promptRepository.save(prompt); + } + + async updatePrompt( + id: string, + data: Partial, + updatedBy?: string + ): Promise { + const prompt = await this.findPrompt(id); + if (!prompt) return null; + + if (prompt.isSystem) { + throw new Error('Cannot update system prompts'); + } + + Object.assign(prompt, data, { updatedBy, version: prompt.version + 1 }); + return this.promptRepository.save(prompt); + } + + async incrementPromptUsage(id: string): Promise { + await this.promptRepository + .createQueryBuilder() + .update() + .set({ + usageCount: () => 'usage_count + 1', + lastUsedAt: new Date(), + }) + .where('id = :id', { id }) + .execute(); + } + + // ============================================ + // CONVERSATIONS + // ============================================ + + async findConversations( + tenantId: string, + filters: ConversationFilters = {}, + limit: number = 50 + ): Promise { + const where: FindOptionsWhere = { tenantId }; + + if (filters.userId) where.userId = filters.userId; + if (filters.modelId) where.modelId = filters.modelId; + if (filters.status) where.status = filters.status as any; + + return this.conversationRepository.find({ + where, + order: { updatedAt: 'DESC' }, + take: limit, + }); + } + + async findConversation(id: string): Promise { + return this.conversationRepository.findOne({ + where: { id }, + relations: ['messages'], + }); + } + + async findUserConversations( + tenantId: string, + userId: string, + limit: number = 20 + ): Promise { + return this.conversationRepository.find({ + where: { tenantId, userId }, + order: { updatedAt: 'DESC' }, + take: limit, + }); + } + + async createConversation( + tenantId: string, + userId: string, + data: Partial + ): Promise { + const conversation = this.conversationRepository.create({ + ...data, + tenantId, + userId, + status: 'active', + }); + return this.conversationRepository.save(conversation); + } + + async updateConversation( + id: string, + data: Partial + ): Promise { + const conversation = await this.conversationRepository.findOne({ where: { id } }); + if (!conversation) return null; + + Object.assign(conversation, data); + return this.conversationRepository.save(conversation); + } + + async archiveConversation(id: string): Promise { + const result = await this.conversationRepository.update(id, { status: 'archived' }); + return (result.affected ?? 0) > 0; + } + + // ============================================ + // MESSAGES + // ============================================ + + async findMessages(conversationId: string): Promise { + return this.messageRepository.find({ + where: { conversationId }, + order: { createdAt: 'ASC' }, + }); + } + + async addMessage(conversationId: string, data: Partial): Promise { + const message = this.messageRepository.create({ + ...data, + conversationId, + }); + + const savedMessage = await this.messageRepository.save(message); + + // Update conversation + await this.conversationRepository + .createQueryBuilder() + .update() + .set({ + messageCount: () => 'message_count + 1', + totalTokens: () => `total_tokens + ${data.totalTokens || 0}`, + updatedAt: new Date(), + }) + .where('id = :id', { id: conversationId }) + .execute(); + + return savedMessage; + } + + async getConversationTokenCount(conversationId: string): Promise { + const result = await this.messageRepository + .createQueryBuilder('message') + .select('SUM(message.total_tokens)', 'total') + .where('message.conversation_id = :conversationId', { conversationId }) + .getRawOne(); + + return parseInt(result?.total) || 0; + } + + // ============================================ + // USAGE & QUOTAS + // ============================================ + + async logUsage(tenantId: string, data: Partial): Promise { + const log = this.usageLogRepository.create({ + ...data, + tenantId, + }); + return this.usageLogRepository.save(log); + } + + async getUsageStats( + tenantId: string, + startDate: Date, + endDate: Date + ): Promise<{ + totalRequests: number; + totalInputTokens: number; + totalOutputTokens: number; + totalCost: number; + byModel: Record; + }> { + const stats = await this.usageLogRepository + .createQueryBuilder('log') + .select('COUNT(*)', 'totalRequests') + .addSelect('SUM(log.input_tokens)', 'totalInputTokens') + .addSelect('SUM(log.output_tokens)', 'totalOutputTokens') + .addSelect('SUM(log.cost_usd)', 'totalCost') + .where('log.tenant_id = :tenantId', { tenantId }) + .andWhere('log.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getRawOne(); + + const byModelStats = await this.usageLogRepository + .createQueryBuilder('log') + .select('log.model_id', 'modelId') + .addSelect('COUNT(*)', 'requests') + .addSelect('SUM(log.input_tokens + log.output_tokens)', 'tokens') + .addSelect('SUM(log.cost_usd)', 'cost') + .where('log.tenant_id = :tenantId', { tenantId }) + .andWhere('log.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy('log.model_id') + .getRawMany(); + + const byModel: Record = {}; + for (const stat of byModelStats) { + byModel[stat.modelId] = { + requests: parseInt(stat.requests) || 0, + tokens: parseInt(stat.tokens) || 0, + cost: parseFloat(stat.cost) || 0, + }; + } + + return { + totalRequests: parseInt(stats?.totalRequests) || 0, + totalInputTokens: parseInt(stats?.totalInputTokens) || 0, + totalOutputTokens: parseInt(stats?.totalOutputTokens) || 0, + totalCost: parseFloat(stats?.totalCost) || 0, + byModel, + }; + } + + async getTenantQuota(tenantId: string): Promise { + return this.quotaRepository.findOne({ where: { tenantId } }); + } + + async updateTenantQuota( + tenantId: string, + data: Partial + ): Promise { + let quota = await this.getTenantQuota(tenantId); + + if (!quota) { + quota = this.quotaRepository.create({ + tenantId, + ...data, + }); + } else { + Object.assign(quota, data); + } + + return this.quotaRepository.save(quota); + } + + async incrementQuotaUsage( + tenantId: string, + requestCount: number, + tokenCount: number, + costUsd: number + ): Promise { + await this.quotaRepository + .createQueryBuilder() + .update() + .set({ + currentRequestsMonth: () => `current_requests_month + ${requestCount}`, + currentTokensMonth: () => `current_tokens_month + ${tokenCount}`, + currentSpendMonth: () => `current_spend_month + ${costUsd}`, + }) + .where('tenant_id = :tenantId', { tenantId }) + .execute(); + } + + async checkQuotaAvailable(tenantId: string): Promise<{ + available: boolean; + reason?: string; + }> { + const quota = await this.getTenantQuota(tenantId); + if (!quota) return { available: true }; + + if (quota.maxRequestsPerMonth && quota.currentRequestsMonth >= quota.maxRequestsPerMonth) { + return { available: false, reason: 'Monthly request limit reached' }; + } + + if (quota.maxTokensPerMonth && quota.currentTokensMonth >= quota.maxTokensPerMonth) { + return { available: false, reason: 'Monthly token limit reached' }; + } + + if (quota.maxSpendPerMonth && quota.currentSpendMonth >= quota.maxSpendPerMonth) { + return { available: false, reason: 'Monthly spend limit reached' }; + } + + return { available: true }; + } + + async resetMonthlyQuotas(): Promise { + const result = await this.quotaRepository.update( + {}, + { + currentRequestsMonth: 0, + currentTokensMonth: 0, + currentSpendMonth: 0, + lastResetAt: new Date(), + } + ); + return result.affected ?? 0; + } +} diff --git a/src/modules/ai/services/index.ts b/src/modules/ai/services/index.ts new file mode 100644 index 0000000..d4fe86b --- /dev/null +++ b/src/modules/ai/services/index.ts @@ -0,0 +1 @@ +export { AIService, ConversationFilters } from './ai.service'; diff --git a/src/modules/audit/audit.module.ts b/src/modules/audit/audit.module.ts new file mode 100644 index 0000000..6686fc8 --- /dev/null +++ b/src/modules/audit/audit.module.ts @@ -0,0 +1,70 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { AuditService } from './services'; +import { AuditController } from './controllers'; +import { + AuditLog, + EntityChange, + LoginHistory, + SensitiveDataAccess, + DataExport, + PermissionChange, + ConfigChange, +} from './entities'; + +export interface AuditModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class AuditModule { + public router: Router; + public auditService: AuditService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: AuditModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const auditLogRepository = this.dataSource.getRepository(AuditLog); + const entityChangeRepository = this.dataSource.getRepository(EntityChange); + const loginHistoryRepository = this.dataSource.getRepository(LoginHistory); + const sensitiveDataAccessRepository = this.dataSource.getRepository(SensitiveDataAccess); + const dataExportRepository = this.dataSource.getRepository(DataExport); + const permissionChangeRepository = this.dataSource.getRepository(PermissionChange); + const configChangeRepository = this.dataSource.getRepository(ConfigChange); + + this.auditService = new AuditService( + auditLogRepository, + entityChangeRepository, + loginHistoryRepository, + sensitiveDataAccessRepository, + dataExportRepository, + permissionChangeRepository, + configChangeRepository + ); + } + + private initializeRoutes(): void { + const auditController = new AuditController(this.auditService); + this.router.use(`${this.basePath}/audit`, auditController.router); + } + + static getEntities(): Function[] { + return [ + AuditLog, + EntityChange, + LoginHistory, + SensitiveDataAccess, + DataExport, + PermissionChange, + ConfigChange, + ]; + } +} diff --git a/src/modules/audit/controllers/audit.controller.ts b/src/modules/audit/controllers/audit.controller.ts new file mode 100644 index 0000000..518c09a --- /dev/null +++ b/src/modules/audit/controllers/audit.controller.ts @@ -0,0 +1,342 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { AuditService, AuditLogFilters } from '../services/audit.service'; + +export class AuditController { + public router: Router; + + constructor(private readonly auditService: AuditService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Audit Logs + this.router.get('/logs', this.findAuditLogs.bind(this)); + this.router.get('/logs/entity/:entityType/:entityId', this.findAuditLogsByEntity.bind(this)); + this.router.post('/logs', this.createAuditLog.bind(this)); + + // Entity Changes + this.router.get('/changes/:entityType/:entityId', this.findEntityChanges.bind(this)); + this.router.get('/changes/:entityType/:entityId/version/:version', this.getEntityVersion.bind(this)); + this.router.post('/changes', this.createEntityChange.bind(this)); + + // Login History + this.router.get('/logins/user/:userId', this.findLoginHistory.bind(this)); + this.router.get('/logins/user/:userId/active-sessions', this.getActiveSessionsCount.bind(this)); + this.router.post('/logins', this.createLoginHistory.bind(this)); + this.router.post('/logins/:sessionId/logout', this.markSessionLogout.bind(this)); + + // Sensitive Data Access + this.router.get('/sensitive-access', this.findSensitiveDataAccess.bind(this)); + this.router.post('/sensitive-access', this.logSensitiveDataAccess.bind(this)); + + // Data Exports + this.router.get('/exports', this.findUserDataExports.bind(this)); + this.router.get('/exports/:id', this.findDataExport.bind(this)); + this.router.post('/exports', this.createDataExport.bind(this)); + this.router.patch('/exports/:id/status', this.updateDataExportStatus.bind(this)); + + // Permission Changes + this.router.get('/permission-changes', this.findPermissionChanges.bind(this)); + this.router.post('/permission-changes', this.logPermissionChange.bind(this)); + + // Config Changes + this.router.get('/config-changes', this.findConfigChanges.bind(this)); + this.router.post('/config-changes', this.logConfigChange.bind(this)); + } + + // ============================================ + // AUDIT LOGS + // ============================================ + + private async findAuditLogs(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const filters: AuditLogFilters = { + userId: req.query.userId as string, + entityType: req.query.entityType as string, + action: req.query.action as string, + category: req.query.category as string, + ipAddress: req.query.ipAddress as string, + }; + + if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string); + if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string); + + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 50; + + const result = await this.auditService.findAuditLogs(tenantId, filters, { page, limit }); + res.json({ data: result.data, total: result.total, page, limit }); + } catch (error) { + next(error); + } + } + + private async findAuditLogsByEntity(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { entityType, entityId } = req.params; + + const logs = await this.auditService.findAuditLogsByEntity(tenantId, entityType, entityId); + res.json({ data: logs, total: logs.length }); + } catch (error) { + next(error); + } + } + + private async createAuditLog(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const log = await this.auditService.createAuditLog(tenantId, req.body); + res.status(201).json({ data: log }); + } catch (error) { + next(error); + } + } + + // ============================================ + // ENTITY CHANGES + // ============================================ + + private async findEntityChanges(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { entityType, entityId } = req.params; + + const changes = await this.auditService.findEntityChanges(tenantId, entityType, entityId); + res.json({ data: changes, total: changes.length }); + } catch (error) { + next(error); + } + } + + private async getEntityVersion(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { entityType, entityId, version } = req.params; + + const change = await this.auditService.getEntityVersion( + tenantId, + entityType, + entityId, + parseInt(version) + ); + + if (!change) { + res.status(404).json({ error: 'Version not found' }); + return; + } + + res.json({ data: change }); + } catch (error) { + next(error); + } + } + + private async createEntityChange(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const change = await this.auditService.createEntityChange(tenantId, req.body); + res.status(201).json({ data: change }); + } catch (error) { + next(error); + } + } + + // ============================================ + // LOGIN HISTORY + // ============================================ + + private async findLoginHistory(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { userId } = req.params; + const limit = parseInt(req.query.limit as string) || 20; + + const history = await this.auditService.findLoginHistory(userId, tenantId, limit); + res.json({ data: history, total: history.length }); + } catch (error) { + next(error); + } + } + + private async getActiveSessionsCount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId } = req.params; + const count = await this.auditService.getActiveSessionsCount(userId); + res.json({ data: { activeSessions: count } }); + } catch (error) { + next(error); + } + } + + private async createLoginHistory(req: Request, res: Response, next: NextFunction): Promise { + try { + const login = await this.auditService.createLoginHistory(req.body); + res.status(201).json({ data: login }); + } catch (error) { + next(error); + } + } + + private async markSessionLogout(req: Request, res: Response, next: NextFunction): Promise { + try { + const { sessionId } = req.params; + const marked = await this.auditService.markSessionLogout(sessionId); + + if (!marked) { + res.status(404).json({ error: 'Session not found' }); + return; + } + + res.json({ data: { success: true } }); + } catch (error) { + next(error); + } + } + + // ============================================ + // SENSITIVE DATA ACCESS + // ============================================ + + private async findSensitiveDataAccess(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const filters: { userId?: string; dataType?: string; startDate?: Date; endDate?: Date } = { + userId: req.query.userId as string, + dataType: req.query.dataType as string, + }; + + if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string); + if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string); + + const access = await this.auditService.findSensitiveDataAccess(tenantId, filters); + res.json({ data: access, total: access.length }); + } catch (error) { + next(error); + } + } + + private async logSensitiveDataAccess(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const access = await this.auditService.logSensitiveDataAccess(tenantId, req.body); + res.status(201).json({ data: access }); + } catch (error) { + next(error); + } + } + + // ============================================ + // DATA EXPORTS + // ============================================ + + private async findUserDataExports(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const exports = await this.auditService.findUserDataExports(tenantId, userId); + res.json({ data: exports, total: exports.length }); + } catch (error) { + next(error); + } + } + + private async findDataExport(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const exportRecord = await this.auditService.findDataExport(id); + + if (!exportRecord) { + res.status(404).json({ error: 'Export not found' }); + return; + } + + res.json({ data: exportRecord }); + } catch (error) { + next(error); + } + } + + private async createDataExport(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const exportRecord = await this.auditService.createDataExport(tenantId, req.body); + res.status(201).json({ data: exportRecord }); + } catch (error) { + next(error); + } + } + + private async updateDataExportStatus(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { status, ...updates } = req.body; + + const exportRecord = await this.auditService.updateDataExportStatus(id, status, updates); + + if (!exportRecord) { + res.status(404).json({ error: 'Export not found' }); + return; + } + + res.json({ data: exportRecord }); + } catch (error) { + next(error); + } + } + + // ============================================ + // PERMISSION CHANGES + // ============================================ + + private async findPermissionChanges(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const targetUserId = req.query.targetUserId as string; + + const changes = await this.auditService.findPermissionChanges(tenantId, targetUserId); + res.json({ data: changes, total: changes.length }); + } catch (error) { + next(error); + } + } + + private async logPermissionChange(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const change = await this.auditService.logPermissionChange(tenantId, req.body); + res.status(201).json({ data: change }); + } catch (error) { + next(error); + } + } + + // ============================================ + // CONFIG CHANGES + // ============================================ + + private async findConfigChanges(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const configType = req.query.configType as string; + + const changes = await this.auditService.findConfigChanges(tenantId, configType); + res.json({ data: changes, total: changes.length }); + } catch (error) { + next(error); + } + } + + private async logConfigChange(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const change = await this.auditService.logConfigChange(tenantId, req.body); + res.status(201).json({ data: change }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/audit/controllers/index.ts b/src/modules/audit/controllers/index.ts new file mode 100644 index 0000000..668948b --- /dev/null +++ b/src/modules/audit/controllers/index.ts @@ -0,0 +1 @@ +export { AuditController } from './audit.controller'; diff --git a/src/modules/audit/dto/audit.dto.ts b/src/modules/audit/dto/audit.dto.ts new file mode 100644 index 0000000..f646e6a --- /dev/null +++ b/src/modules/audit/dto/audit.dto.ts @@ -0,0 +1,346 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsObject, + IsUUID, + IsEnum, + IsIP, + MaxLength, + MinLength, +} from 'class-validator'; + +// ============================================ +// AUDIT LOG DTOs +// ============================================ + +export class CreateAuditLogDto { + @IsOptional() + @IsUUID() + userId?: string; + + @IsString() + @MaxLength(20) + action: string; + + @IsOptional() + @IsString() + @MaxLength(30) + category?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + entityType?: string; + + @IsOptional() + @IsUUID() + entityId?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsObject() + oldValues?: Record; + + @IsOptional() + @IsObject() + newValues?: Record; + + @IsOptional() + @IsObject() + metadata?: Record; + + @IsOptional() + @IsString() + @MaxLength(45) + ipAddress?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + userAgent?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + requestId?: string; +} + +// ============================================ +// ENTITY CHANGE DTOs +// ============================================ + +export class CreateEntityChangeDto { + @IsString() + @MaxLength(100) + entityType: string; + + @IsUUID() + entityId: string; + + @IsString() + @MaxLength(20) + changeType: string; + + @IsOptional() + @IsUUID() + changedBy?: string; + + @IsNumber() + version: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + changedFields?: string[]; + + @IsOptional() + @IsObject() + previousData?: Record; + + @IsOptional() + @IsObject() + newData?: Record; + + @IsOptional() + @IsString() + changeReason?: string; +} + +// ============================================ +// LOGIN HISTORY DTOs +// ============================================ + +export class CreateLoginHistoryDto { + @IsUUID() + userId: string; + + @IsOptional() + @IsUUID() + tenantId?: string; + + @IsString() + @MaxLength(20) + status: string; + + @IsOptional() + @IsString() + @MaxLength(30) + authMethod?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + mfaMethod?: string; + + @IsOptional() + @IsBoolean() + mfaUsed?: boolean; + + @IsOptional() + @IsString() + @MaxLength(45) + ipAddress?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + userAgent?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + deviceFingerprint?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + location?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + sessionId?: string; + + @IsOptional() + @IsString() + failureReason?: string; +} + +// ============================================ +// SENSITIVE DATA ACCESS DTOs +// ============================================ + +export class CreateSensitiveDataAccessDto { + @IsUUID() + userId: string; + + @IsString() + @MaxLength(50) + dataType: string; + + @IsString() + @MaxLength(20) + accessType: string; + + @IsOptional() + @IsString() + @MaxLength(100) + entityType?: string; + + @IsOptional() + @IsUUID() + entityId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + fieldsAccessed?: string[]; + + @IsOptional() + @IsString() + accessReason?: string; + + @IsOptional() + @IsBoolean() + wasExported?: boolean; + + @IsOptional() + @IsString() + @MaxLength(45) + ipAddress?: string; +} + +// ============================================ +// DATA EXPORT DTOs +// ============================================ + +export class CreateDataExportDto { + @IsString() + @MaxLength(30) + exportType: string; + + @IsString() + @MaxLength(20) + format: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + entities?: string[]; + + @IsOptional() + @IsObject() + filters?: Record; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + fields?: string[]; + + @IsOptional() + @IsString() + exportReason?: string; +} + +export class UpdateDataExportStatusDto { + @IsString() + @MaxLength(20) + status: string; + + @IsOptional() + @IsString() + filePath?: string; + + @IsOptional() + @IsNumber() + fileSize?: number; + + @IsOptional() + @IsNumber() + recordCount?: number; + + @IsOptional() + @IsString() + errorMessage?: string; +} + +// ============================================ +// PERMISSION CHANGE DTOs +// ============================================ + +export class CreatePermissionChangeDto { + @IsUUID() + targetUserId: string; + + @IsUUID() + changedBy: string; + + @IsString() + @MaxLength(20) + changeType: string; + + @IsString() + @MaxLength(30) + scope: string; + + @IsOptional() + @IsString() + @MaxLength(100) + resourceType?: string; + + @IsOptional() + @IsUUID() + resourceId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + previousPermissions?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + newPermissions?: string[]; + + @IsOptional() + @IsString() + changeReason?: string; +} + +// ============================================ +// CONFIG CHANGE DTOs +// ============================================ + +export class CreateConfigChangeDto { + @IsString() + @MaxLength(30) + configType: string; + + @IsString() + @MaxLength(200) + configKey: string; + + @IsUUID() + changedBy: string; + + @IsNumber() + version: number; + + @IsOptional() + @IsObject() + previousValue?: Record; + + @IsOptional() + @IsObject() + newValue?: Record; + + @IsOptional() + @IsString() + changeReason?: string; +} diff --git a/src/modules/audit/dto/index.ts b/src/modules/audit/dto/index.ts new file mode 100644 index 0000000..51a4ace --- /dev/null +++ b/src/modules/audit/dto/index.ts @@ -0,0 +1,10 @@ +export { + CreateAuditLogDto, + CreateEntityChangeDto, + CreateLoginHistoryDto, + CreateSensitiveDataAccessDto, + CreateDataExportDto, + UpdateDataExportStatusDto, + CreatePermissionChangeDto, + CreateConfigChangeDto, +} from './audit.dto'; diff --git a/src/modules/audit/entities/audit-log.entity.ts b/src/modules/audit/entities/audit-log.entity.ts new file mode 100644 index 0000000..6fd98e7 --- /dev/null +++ b/src/modules/audit/entities/audit-log.entity.ts @@ -0,0 +1,108 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type AuditAction = 'create' | 'read' | 'update' | 'delete' | 'login' | 'logout' | 'export'; +export type AuditCategory = 'data' | 'auth' | 'system' | 'config' | 'billing'; +export type AuditStatus = 'success' | 'failure' | 'partial'; + +@Entity({ name: 'audit_logs', schema: 'audit' }) +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'user_email', type: 'varchar', length: 255, nullable: true }) + userEmail: string; + + @Column({ name: 'user_name', type: 'varchar', length: 200, nullable: true }) + userName: string; + + @Column({ name: 'session_id', type: 'uuid', nullable: true }) + sessionId: string; + + @Column({ name: 'impersonator_id', type: 'uuid', nullable: true }) + impersonatorId: string; + + @Index() + @Column({ name: 'action', type: 'varchar', length: 50 }) + action: AuditAction; + + @Index() + @Column({ name: 'action_category', type: 'varchar', length: 50, nullable: true }) + actionCategory: AuditCategory; + + @Index() + @Column({ name: 'resource_type', type: 'varchar', length: 100 }) + resourceType: string; + + @Column({ name: 'resource_id', type: 'uuid', nullable: true }) + resourceId: string; + + @Column({ name: 'resource_name', type: 'varchar', length: 255, nullable: true }) + resourceName: string; + + @Column({ name: 'old_values', type: 'jsonb', nullable: true }) + oldValues: Record; + + @Column({ name: 'new_values', type: 'jsonb', nullable: true }) + newValues: Record; + + @Column({ name: 'changed_fields', type: 'text', array: true, nullable: true }) + changedFields: string[]; + + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Column({ name: 'device_info', type: 'jsonb', default: {} }) + deviceInfo: Record; + + @Column({ name: 'location', type: 'jsonb', default: {} }) + location: Record; + + @Column({ name: 'request_id', type: 'varchar', length: 100, nullable: true }) + requestId: string; + + @Column({ name: 'request_method', type: 'varchar', length: 10, nullable: true }) + requestMethod: string; + + @Column({ name: 'request_path', type: 'text', nullable: true }) + requestPath: string; + + @Column({ name: 'request_params', type: 'jsonb', default: {} }) + requestParams: Record; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'success' }) + status: AuditStatus; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'duration_ms', type: 'int', nullable: true }) + durationMs: number; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/audit/entities/config-change.entity.ts b/src/modules/audit/entities/config-change.entity.ts new file mode 100644 index 0000000..f9b3a69 --- /dev/null +++ b/src/modules/audit/entities/config-change.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type ConfigType = 'tenant_settings' | 'user_settings' | 'system_settings' | 'feature_flags'; + +@Entity({ name: 'config_changes', schema: 'audit' }) +export class ConfigChange { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Column({ name: 'changed_by', type: 'uuid' }) + changedBy: string; + + @Index() + @Column({ name: 'config_type', type: 'varchar', length: 50 }) + configType: ConfigType; + + @Column({ name: 'config_key', type: 'varchar', length: 100 }) + configKey: string; + + @Column({ name: 'config_path', type: 'text', nullable: true }) + configPath: string; + + @Column({ name: 'old_value', type: 'jsonb', nullable: true }) + oldValue: Record; + + @Column({ name: 'new_value', type: 'jsonb', nullable: true }) + newValue: Record; + + @Column({ name: 'reason', type: 'text', nullable: true }) + reason: string; + + @Column({ name: 'ticket_id', type: 'varchar', length: 50, nullable: true }) + ticketId: string; + + @Index() + @Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + changedAt: Date; +} diff --git a/src/modules/audit/entities/data-export.entity.ts b/src/modules/audit/entities/data-export.entity.ts new file mode 100644 index 0000000..727bf36 --- /dev/null +++ b/src/modules/audit/entities/data-export.entity.ts @@ -0,0 +1,80 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type ExportType = 'report' | 'backup' | 'gdpr_request' | 'bulk_export'; +export type ExportFormat = 'csv' | 'xlsx' | 'pdf' | 'json'; +export type ExportStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'expired'; + +@Entity({ name: 'data_exports', schema: 'audit' }) +export class DataExport { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'export_type', type: 'varchar', length: 50 }) + exportType: ExportType; + + @Column({ name: 'export_format', type: 'varchar', length: 20, nullable: true }) + exportFormat: ExportFormat; + + @Column({ name: 'entity_types', type: 'text', array: true }) + entityTypes: string[]; + + @Column({ name: 'filters', type: 'jsonb', default: {} }) + filters: Record; + + @Column({ name: 'date_range_start', type: 'timestamptz', nullable: true }) + dateRangeStart: Date; + + @Column({ name: 'date_range_end', type: 'timestamptz', nullable: true }) + dateRangeEnd: Date; + + @Column({ name: 'record_count', type: 'int', nullable: true }) + recordCount: number; + + @Column({ name: 'file_size_bytes', type: 'bigint', nullable: true }) + fileSizeBytes: number; + + @Column({ name: 'file_hash', type: 'varchar', length: 64, nullable: true }) + fileHash: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: ExportStatus; + + @Column({ name: 'download_url', type: 'text', nullable: true }) + downloadUrl: string; + + @Column({ name: 'download_expires_at', type: 'timestamptz', nullable: true }) + downloadExpiresAt: Date; + + @Column({ name: 'download_count', type: 'int', default: 0 }) + downloadCount: number; + + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Index() + @Column({ name: 'requested_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + requestedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; +} diff --git a/src/modules/audit/entities/entity-change.entity.ts b/src/modules/audit/entities/entity-change.entity.ts new file mode 100644 index 0000000..b2e208e --- /dev/null +++ b/src/modules/audit/entities/entity-change.entity.ts @@ -0,0 +1,55 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type ChangeType = 'create' | 'update' | 'delete' | 'restore'; + +@Entity({ name: 'entity_changes', schema: 'audit' }) +export class EntityChange { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'entity_type', type: 'varchar', length: 100 }) + entityType: string; + + @Index() + @Column({ name: 'entity_id', type: 'uuid' }) + entityId: string; + + @Column({ name: 'entity_name', type: 'varchar', length: 255, nullable: true }) + entityName: string; + + @Column({ name: 'version', type: 'int', default: 1 }) + version: number; + + @Column({ name: 'previous_version', type: 'int', nullable: true }) + previousVersion: number; + + @Column({ name: 'data_snapshot', type: 'jsonb' }) + dataSnapshot: Record; + + @Column({ name: 'changes', type: 'jsonb', default: [] }) + changes: Record[]; + + @Index() + @Column({ name: 'changed_by', type: 'uuid', nullable: true }) + changedBy: string; + + @Column({ name: 'change_reason', type: 'text', nullable: true }) + changeReason: string; + + @Column({ name: 'change_type', type: 'varchar', length: 20 }) + changeType: ChangeType; + + @Index() + @Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + changedAt: Date; +} diff --git a/src/modules/audit/entities/index.ts b/src/modules/audit/entities/index.ts new file mode 100644 index 0000000..e0f3abd --- /dev/null +++ b/src/modules/audit/entities/index.ts @@ -0,0 +1,7 @@ +export { AuditLog, AuditAction, AuditCategory, AuditStatus } from './audit-log.entity'; +export { EntityChange, ChangeType } from './entity-change.entity'; +export { LoginHistory, LoginStatus, AuthMethod, MfaMethod } from './login-history.entity'; +export { SensitiveDataAccess, DataType, AccessType } from './sensitive-data-access.entity'; +export { DataExport, ExportType, ExportFormat, ExportStatus } from './data-export.entity'; +export { PermissionChange, PermissionChangeType, PermissionScope } from './permission-change.entity'; +export { ConfigChange, ConfigType } from './config-change.entity'; diff --git a/src/modules/audit/entities/login-history.entity.ts b/src/modules/audit/entities/login-history.entity.ts new file mode 100644 index 0000000..d90123d --- /dev/null +++ b/src/modules/audit/entities/login-history.entity.ts @@ -0,0 +1,106 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type LoginStatus = 'success' | 'failed' | 'blocked' | 'mfa_required' | 'mfa_failed'; +export type AuthMethod = 'password' | 'sso' | 'oauth' | 'mfa' | 'magic_link' | 'biometric'; +export type MfaMethod = 'totp' | 'sms' | 'email' | 'push'; + +@Entity({ name: 'login_history', schema: 'audit' }) +export class LoginHistory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'email', type: 'varchar', length: 255, nullable: true }) + email: string; + + @Column({ name: 'username', type: 'varchar', length: 100, nullable: true }) + username: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20 }) + status: LoginStatus; + + @Column({ name: 'auth_method', type: 'varchar', length: 30, nullable: true }) + authMethod: AuthMethod; + + @Column({ name: 'oauth_provider', type: 'varchar', length: 30, nullable: true }) + oauthProvider: string; + + @Column({ name: 'mfa_method', type: 'varchar', length: 20, nullable: true }) + mfaMethod: MfaMethod; + + @Column({ name: 'mfa_verified', type: 'boolean', nullable: true }) + mfaVerified: boolean; + + @Column({ name: 'device_id', type: 'uuid', nullable: true }) + deviceId: string; + + @Column({ name: 'device_fingerprint', type: 'varchar', length: 255, nullable: true }) + deviceFingerprint: string; + + @Column({ name: 'device_type', type: 'varchar', length: 30, nullable: true }) + deviceType: string; + + @Column({ name: 'device_os', type: 'varchar', length: 50, nullable: true }) + deviceOs: string; + + @Column({ name: 'device_browser', type: 'varchar', length: 50, nullable: true }) + deviceBrowser: string; + + @Index() + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Column({ name: 'country_code', type: 'varchar', length: 2, nullable: true }) + countryCode: string; + + @Column({ name: 'city', type: 'varchar', length: 100, nullable: true }) + city: string; + + @Column({ name: 'latitude', type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ name: 'longitude', type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + @Column({ name: 'risk_score', type: 'int', nullable: true }) + riskScore: number; + + @Column({ name: 'risk_factors', type: 'jsonb', default: [] }) + riskFactors: string[]; + + @Index() + @Column({ name: 'is_suspicious', type: 'boolean', default: false }) + isSuspicious: boolean; + + @Column({ name: 'is_new_device', type: 'boolean', default: false }) + isNewDevice: boolean; + + @Column({ name: 'is_new_location', type: 'boolean', default: false }) + isNewLocation: boolean; + + @Column({ name: 'failure_reason', type: 'varchar', length: 100, nullable: true }) + failureReason: string; + + @Column({ name: 'failure_count', type: 'int', nullable: true }) + failureCount: number; + + @Index() + @Column({ name: 'attempted_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + attemptedAt: Date; +} diff --git a/src/modules/audit/entities/permission-change.entity.ts b/src/modules/audit/entities/permission-change.entity.ts new file mode 100644 index 0000000..b673a6a --- /dev/null +++ b/src/modules/audit/entities/permission-change.entity.ts @@ -0,0 +1,63 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type PermissionChangeType = 'role_assigned' | 'role_revoked' | 'permission_granted' | 'permission_revoked'; +export type PermissionScope = 'global' | 'tenant' | 'branch'; + +@Entity({ name: 'permission_changes', schema: 'audit' }) +export class PermissionChange { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'changed_by', type: 'uuid' }) + changedBy: string; + + @Index() + @Column({ name: 'target_user_id', type: 'uuid' }) + targetUserId: string; + + @Column({ name: 'target_user_email', type: 'varchar', length: 255, nullable: true }) + targetUserEmail: string; + + @Column({ name: 'change_type', type: 'varchar', length: 30 }) + changeType: PermissionChangeType; + + @Column({ name: 'role_id', type: 'uuid', nullable: true }) + roleId: string; + + @Column({ name: 'role_code', type: 'varchar', length: 50, nullable: true }) + roleCode: string; + + @Column({ name: 'permission_id', type: 'uuid', nullable: true }) + permissionId: string; + + @Column({ name: 'permission_code', type: 'varchar', length: 100, nullable: true }) + permissionCode: string; + + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + @Column({ name: 'scope', type: 'varchar', length: 30, nullable: true }) + scope: PermissionScope; + + @Column({ name: 'previous_roles', type: 'text', array: true, nullable: true }) + previousRoles: string[]; + + @Column({ name: 'previous_permissions', type: 'text', array: true, nullable: true }) + previousPermissions: string[]; + + @Column({ name: 'reason', type: 'text', nullable: true }) + reason: string; + + @Index() + @Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + changedAt: Date; +} diff --git a/src/modules/audit/entities/sensitive-data-access.entity.ts b/src/modules/audit/entities/sensitive-data-access.entity.ts new file mode 100644 index 0000000..140c0eb --- /dev/null +++ b/src/modules/audit/entities/sensitive-data-access.entity.ts @@ -0,0 +1,62 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + Index, +} from 'typeorm'; + +export type DataType = 'pii' | 'financial' | 'medical' | 'credentials'; +export type AccessType = 'view' | 'export' | 'modify' | 'decrypt'; + +@Entity({ name: 'sensitive_data_access', schema: 'audit' }) +export class SensitiveDataAccess { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'session_id', type: 'uuid', nullable: true }) + sessionId: string; + + @Index() + @Column({ name: 'data_type', type: 'varchar', length: 100 }) + dataType: DataType; + + @Column({ name: 'data_category', type: 'varchar', length: 100, nullable: true }) + dataCategory: string; + + @Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true }) + entityType: string; + + @Column({ name: 'entity_id', type: 'uuid', nullable: true }) + entityId: string; + + @Column({ name: 'access_type', type: 'varchar', length: 30 }) + accessType: AccessType; + + @Column({ name: 'access_reason', type: 'text', nullable: true }) + accessReason: string; + + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Index() + @Column({ name: 'was_authorized', type: 'boolean', default: true }) + wasAuthorized: boolean; + + @Column({ name: 'denial_reason', type: 'text', nullable: true }) + denialReason: string; + + @Index() + @Column({ name: 'accessed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + accessedAt: Date; +} diff --git a/src/modules/audit/index.ts b/src/modules/audit/index.ts new file mode 100644 index 0000000..c9df41c --- /dev/null +++ b/src/modules/audit/index.ts @@ -0,0 +1,5 @@ +export { AuditModule, AuditModuleOptions } from './audit.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/audit/services/audit.service.ts b/src/modules/audit/services/audit.service.ts new file mode 100644 index 0000000..7a1e14b --- /dev/null +++ b/src/modules/audit/services/audit.service.ts @@ -0,0 +1,303 @@ +import { Repository, FindOptionsWhere, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { + AuditLog, + EntityChange, + LoginHistory, + SensitiveDataAccess, + DataExport, + PermissionChange, + ConfigChange, +} from '../entities'; + +export interface AuditLogFilters { + userId?: string; + entityType?: string; + action?: string; + category?: string; + startDate?: Date; + endDate?: Date; + ipAddress?: string; +} + +export interface PaginationOptions { + page?: number; + limit?: number; +} + +export class AuditService { + constructor( + private readonly auditLogRepository: Repository, + private readonly entityChangeRepository: Repository, + private readonly loginHistoryRepository: Repository, + private readonly sensitiveDataAccessRepository: Repository, + private readonly dataExportRepository: Repository, + private readonly permissionChangeRepository: Repository, + private readonly configChangeRepository: Repository + ) {} + + // ============================================ + // AUDIT LOGS + // ============================================ + + async createAuditLog(tenantId: string, data: Partial): Promise { + const log = this.auditLogRepository.create({ + ...data, + tenantId, + }); + return this.auditLogRepository.save(log); + } + + async findAuditLogs( + tenantId: string, + filters: AuditLogFilters = {}, + pagination: PaginationOptions = {} + ): Promise<{ data: AuditLog[]; total: number }> { + const { page = 1, limit = 50 } = pagination; + const where: FindOptionsWhere = { tenantId }; + + if (filters.userId) where.userId = filters.userId; + if (filters.entityType) where.entityType = filters.entityType; + if (filters.action) where.action = filters.action as any; + if (filters.category) where.category = filters.category as any; + if (filters.ipAddress) where.ipAddress = filters.ipAddress; + + if (filters.startDate && filters.endDate) { + where.createdAt = Between(filters.startDate, filters.endDate); + } else if (filters.startDate) { + where.createdAt = MoreThanOrEqual(filters.startDate); + } else if (filters.endDate) { + where.createdAt = LessThanOrEqual(filters.endDate); + } + + const [data, total] = await this.auditLogRepository.findAndCount({ + where, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { data, total }; + } + + async findAuditLogsByEntity( + tenantId: string, + entityType: string, + entityId: string + ): Promise { + return this.auditLogRepository.find({ + where: { tenantId, entityType, entityId }, + order: { createdAt: 'DESC' }, + }); + } + + // ============================================ + // ENTITY CHANGES + // ============================================ + + async createEntityChange(tenantId: string, data: Partial): Promise { + const change = this.entityChangeRepository.create({ + ...data, + tenantId, + }); + return this.entityChangeRepository.save(change); + } + + async findEntityChanges( + tenantId: string, + entityType: string, + entityId: string + ): Promise { + return this.entityChangeRepository.find({ + where: { tenantId, entityType, entityId }, + order: { changedAt: 'DESC' }, + }); + } + + async getEntityVersion( + tenantId: string, + entityType: string, + entityId: string, + version: number + ): Promise { + return this.entityChangeRepository.findOne({ + where: { tenantId, entityType, entityId, version }, + }); + } + + // ============================================ + // LOGIN HISTORY + // ============================================ + + async createLoginHistory(data: Partial): Promise { + const login = this.loginHistoryRepository.create(data); + return this.loginHistoryRepository.save(login); + } + + async findLoginHistory( + userId: string, + tenantId?: string, + limit: number = 20 + ): Promise { + const where: FindOptionsWhere = { userId }; + if (tenantId) where.tenantId = tenantId; + + return this.loginHistoryRepository.find({ + where, + order: { loginAt: 'DESC' }, + take: limit, + }); + } + + async getActiveSessionsCount(userId: string): Promise { + return this.loginHistoryRepository.count({ + where: { userId, logoutAt: undefined, status: 'success' }, + }); + } + + async markSessionLogout(sessionId: string): Promise { + const result = await this.loginHistoryRepository.update( + { sessionId }, + { logoutAt: new Date() } + ); + return (result.affected ?? 0) > 0; + } + + // ============================================ + // SENSITIVE DATA ACCESS + // ============================================ + + async logSensitiveDataAccess( + tenantId: string, + data: Partial + ): Promise { + const access = this.sensitiveDataAccessRepository.create({ + ...data, + tenantId, + }); + return this.sensitiveDataAccessRepository.save(access); + } + + async findSensitiveDataAccess( + tenantId: string, + filters: { userId?: string; dataType?: string; startDate?: Date; endDate?: Date } = {} + ): Promise { + const where: FindOptionsWhere = { tenantId }; + + if (filters.userId) where.userId = filters.userId; + if (filters.dataType) where.dataType = filters.dataType as any; + + if (filters.startDate && filters.endDate) { + where.accessedAt = Between(filters.startDate, filters.endDate); + } + + return this.sensitiveDataAccessRepository.find({ + where, + order: { accessedAt: 'DESC' }, + take: 100, + }); + } + + // ============================================ + // DATA EXPORTS + // ============================================ + + async createDataExport(tenantId: string, data: Partial): Promise { + const exportRecord = this.dataExportRepository.create({ + ...data, + tenantId, + status: 'pending', + }); + return this.dataExportRepository.save(exportRecord); + } + + async findDataExport(id: string): Promise { + return this.dataExportRepository.findOne({ where: { id } }); + } + + async findUserDataExports(tenantId: string, userId: string): Promise { + return this.dataExportRepository.find({ + where: { tenantId, requestedBy: userId }, + order: { requestedAt: 'DESC' }, + }); + } + + async updateDataExportStatus( + id: string, + status: string, + updates: Partial = {} + ): Promise { + const exportRecord = await this.findDataExport(id); + if (!exportRecord) return null; + + exportRecord.status = status as any; + Object.assign(exportRecord, updates); + + if (status === 'completed') { + exportRecord.completedAt = new Date(); + } + + return this.dataExportRepository.save(exportRecord); + } + + // ============================================ + // PERMISSION CHANGES + // ============================================ + + async logPermissionChange( + tenantId: string, + data: Partial + ): Promise { + const change = this.permissionChangeRepository.create({ + ...data, + tenantId, + }); + return this.permissionChangeRepository.save(change); + } + + async findPermissionChanges( + tenantId: string, + targetUserId?: string + ): Promise { + const where: FindOptionsWhere = { tenantId }; + if (targetUserId) where.targetUserId = targetUserId; + + return this.permissionChangeRepository.find({ + where, + order: { changedAt: 'DESC' }, + take: 100, + }); + } + + // ============================================ + // CONFIG CHANGES + // ============================================ + + async logConfigChange(tenantId: string, data: Partial): Promise { + const change = this.configChangeRepository.create({ + ...data, + tenantId, + }); + return this.configChangeRepository.save(change); + } + + async findConfigChanges(tenantId: string, configType?: string): Promise { + const where: FindOptionsWhere = { tenantId }; + if (configType) where.configType = configType as any; + + return this.configChangeRepository.find({ + where, + order: { changedAt: 'DESC' }, + take: 100, + }); + } + + async getConfigVersion( + tenantId: string, + configKey: string, + version: number + ): Promise { + return this.configChangeRepository.findOne({ + where: { tenantId, configKey, version }, + }); + } +} diff --git a/src/modules/audit/services/index.ts b/src/modules/audit/services/index.ts new file mode 100644 index 0000000..4e17eb0 --- /dev/null +++ b/src/modules/audit/services/index.ts @@ -0,0 +1 @@ +export { AuditService, AuditLogFilters, PaginationOptions } from './audit.service'; 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/billing-usage/billing-usage.module.ts b/src/modules/billing-usage/billing-usage.module.ts new file mode 100644 index 0000000..69d63e4 --- /dev/null +++ b/src/modules/billing-usage/billing-usage.module.ts @@ -0,0 +1,60 @@ +/** + * Billing Usage Module + * + * Module registration for billing and usage tracking + */ + +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { + SubscriptionPlansController, + SubscriptionsController, + UsageController, + InvoicesController, +} from './controllers'; + +export interface BillingUsageModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class BillingUsageModule { + public router: Router; + private subscriptionPlansController: SubscriptionPlansController; + private subscriptionsController: SubscriptionsController; + private usageController: UsageController; + private invoicesController: InvoicesController; + + constructor(options: BillingUsageModuleOptions) { + const { dataSource, basePath = '/billing' } = options; + + this.router = Router(); + + // Initialize controllers + this.subscriptionPlansController = new SubscriptionPlansController(dataSource); + this.subscriptionsController = new SubscriptionsController(dataSource); + this.usageController = new UsageController(dataSource); + this.invoicesController = new InvoicesController(dataSource); + + // Register routes + this.router.use(`${basePath}/subscription-plans`, this.subscriptionPlansController.router); + this.router.use(`${basePath}/subscriptions`, this.subscriptionsController.router); + this.router.use(`${basePath}/usage`, this.usageController.router); + this.router.use(`${basePath}/invoices`, this.invoicesController.router); + } + + /** + * Get all entities for this module (for TypeORM configuration) + */ + static getEntities() { + return [ + require('./entities/subscription-plan.entity').SubscriptionPlan, + require('./entities/tenant-subscription.entity').TenantSubscription, + require('./entities/usage-tracking.entity').UsageTracking, + require('./entities/invoice.entity').Invoice, + require('./entities/invoice-item.entity').InvoiceItem, + ]; + } +} + +export default BillingUsageModule; diff --git a/src/modules/billing-usage/controllers/index.ts b/src/modules/billing-usage/controllers/index.ts new file mode 100644 index 0000000..f529d57 --- /dev/null +++ b/src/modules/billing-usage/controllers/index.ts @@ -0,0 +1,8 @@ +/** + * Billing Usage Controllers Index + */ + +export { SubscriptionPlansController } from './subscription-plans.controller'; +export { SubscriptionsController } from './subscriptions.controller'; +export { UsageController } from './usage.controller'; +export { InvoicesController } from './invoices.controller'; diff --git a/src/modules/billing-usage/controllers/invoices.controller.ts b/src/modules/billing-usage/controllers/invoices.controller.ts new file mode 100644 index 0000000..5ead18f --- /dev/null +++ b/src/modules/billing-usage/controllers/invoices.controller.ts @@ -0,0 +1,258 @@ +/** + * Invoices Controller + * + * REST API endpoints for invoice management + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { InvoicesService } from '../services'; +import { + CreateInvoiceDto, + UpdateInvoiceDto, + RecordPaymentDto, + VoidInvoiceDto, + RefundInvoiceDto, + GenerateInvoiceDto, + InvoiceFilterDto, +} from '../dto'; + +export class InvoicesController { + public router: Router; + private service: InvoicesService; + + constructor(dataSource: DataSource) { + this.router = Router(); + this.service = new InvoicesService(dataSource); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Stats + this.router.get('/stats', this.getStats.bind(this)); + + // List and search + this.router.get('/', this.getAll.bind(this)); + this.router.get('/tenant/:tenantId', this.getByTenant.bind(this)); + this.router.get('/:id', this.getById.bind(this)); + this.router.get('/number/:invoiceNumber', this.getByNumber.bind(this)); + + // Create + this.router.post('/', this.create.bind(this)); + this.router.post('/generate', this.generate.bind(this)); + + // Update + this.router.put('/:id', this.update.bind(this)); + + // Actions + this.router.post('/:id/send', this.send.bind(this)); + this.router.post('/:id/payment', this.recordPayment.bind(this)); + this.router.post('/:id/void', this.void.bind(this)); + this.router.post('/:id/refund', this.refund.bind(this)); + + // Batch operations + this.router.post('/mark-overdue', this.markOverdue.bind(this)); + } + + /** + * GET /invoices/stats + * Get invoice statistics + */ + private async getStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const { tenantId } = req.query; + const stats = await this.service.getStats(tenantId as string); + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + /** + * GET /invoices + * Get all invoices with filters + */ + private async getAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const filter: InvoiceFilterDto = { + tenantId: req.query.tenantId as string, + status: req.query.status as any, + dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined, + dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined, + overdue: req.query.overdue === 'true', + limit: req.query.limit ? parseInt(req.query.limit as string) : undefined, + offset: req.query.offset ? parseInt(req.query.offset as string) : undefined, + }; + + const result = await this.service.findAll(filter); + res.json(result); + } catch (error) { + next(error); + } + } + + /** + * GET /invoices/tenant/:tenantId + * Get invoices for specific tenant + */ + private async getByTenant(req: Request, res: Response, next: NextFunction): Promise { + try { + const result = await this.service.findAll({ + tenantId: req.params.tenantId, + limit: req.query.limit ? parseInt(req.query.limit as string) : 50, + offset: req.query.offset ? parseInt(req.query.offset as string) : 0, + }); + res.json(result); + } catch (error) { + next(error); + } + } + + /** + * GET /invoices/:id + * Get invoice by ID + */ + private async getById(req: Request, res: Response, next: NextFunction): Promise { + try { + const invoice = await this.service.findById(req.params.id); + + if (!invoice) { + res.status(404).json({ error: 'Invoice not found' }); + return; + } + + res.json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * GET /invoices/number/:invoiceNumber + * Get invoice by number + */ + private async getByNumber(req: Request, res: Response, next: NextFunction): Promise { + try { + const invoice = await this.service.findByNumber(req.params.invoiceNumber); + + if (!invoice) { + res.status(404).json({ error: 'Invoice not found' }); + return; + } + + res.json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * POST /invoices + * Create invoice manually + */ + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: CreateInvoiceDto = req.body; + const invoice = await this.service.create(dto); + res.status(201).json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * POST /invoices/generate + * Generate invoice from subscription + */ + private async generate(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: GenerateInvoiceDto = req.body; + const invoice = await this.service.generateFromSubscription(dto); + res.status(201).json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * PUT /invoices/:id + * Update invoice + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: UpdateInvoiceDto = req.body; + const invoice = await this.service.update(req.params.id, dto); + res.json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * POST /invoices/:id/send + * Send invoice to customer + */ + private async send(req: Request, res: Response, next: NextFunction): Promise { + try { + const invoice = await this.service.send(req.params.id); + res.json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * POST /invoices/:id/payment + * Record payment on invoice + */ + private async recordPayment(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: RecordPaymentDto = req.body; + const invoice = await this.service.recordPayment(req.params.id, dto); + res.json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * POST /invoices/:id/void + * Void an invoice + */ + private async void(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: VoidInvoiceDto = req.body; + const invoice = await this.service.void(req.params.id, dto); + res.json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * POST /invoices/:id/refund + * Refund an invoice + */ + private async refund(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: RefundInvoiceDto = req.body; + const invoice = await this.service.refund(req.params.id, dto); + res.json({ data: invoice }); + } catch (error) { + next(error); + } + } + + /** + * POST /invoices/mark-overdue + * Mark all overdue invoices (scheduled job endpoint) + */ + private async markOverdue(req: Request, res: Response, next: NextFunction): Promise { + try { + const count = await this.service.markOverdueInvoices(); + res.json({ data: { markedOverdue: count } }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/billing-usage/controllers/subscription-plans.controller.ts b/src/modules/billing-usage/controllers/subscription-plans.controller.ts new file mode 100644 index 0000000..5a4ae2f --- /dev/null +++ b/src/modules/billing-usage/controllers/subscription-plans.controller.ts @@ -0,0 +1,168 @@ +/** + * Subscription Plans Controller + * + * REST API endpoints for subscription plan management + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { SubscriptionPlansService } from '../services'; +import { CreateSubscriptionPlanDto, UpdateSubscriptionPlanDto } from '../dto'; + +export class SubscriptionPlansController { + public router: Router; + private service: SubscriptionPlansService; + + constructor(dataSource: DataSource) { + this.router = Router(); + this.service = new SubscriptionPlansService(dataSource); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Public routes + this.router.get('/public', this.getPublicPlans.bind(this)); + this.router.get('/:id/compare/:otherId', this.comparePlans.bind(this)); + + // Protected routes (require admin) + this.router.get('/', this.getAll.bind(this)); + this.router.get('/:id', this.getById.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.put('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + this.router.patch('/:id/activate', this.activate.bind(this)); + this.router.patch('/:id/deactivate', this.deactivate.bind(this)); + } + + /** + * GET /subscription-plans/public + * Get public plans for pricing page + */ + private async getPublicPlans(req: Request, res: Response, next: NextFunction): Promise { + try { + const plans = await this.service.findPublicPlans(); + res.json({ data: plans }); + } catch (error) { + next(error); + } + } + + /** + * GET /subscription-plans + * Get all plans (admin only) + */ + private async getAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const { isActive, isPublic, planType } = req.query; + + const plans = await this.service.findAll({ + isActive: isActive !== undefined ? isActive === 'true' : undefined, + isPublic: isPublic !== undefined ? isPublic === 'true' : undefined, + planType: planType as any, + }); + + res.json({ data: plans }); + } catch (error) { + next(error); + } + } + + /** + * GET /subscription-plans/:id + * Get plan by ID + */ + private async getById(req: Request, res: Response, next: NextFunction): Promise { + try { + const plan = await this.service.findById(req.params.id); + + if (!plan) { + res.status(404).json({ error: 'Plan not found' }); + return; + } + + res.json({ data: plan }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscription-plans + * Create new plan + */ + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: CreateSubscriptionPlanDto = req.body; + const plan = await this.service.create(dto); + res.status(201).json({ data: plan }); + } catch (error) { + next(error); + } + } + + /** + * PUT /subscription-plans/:id + * Update plan + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: UpdateSubscriptionPlanDto = req.body; + const plan = await this.service.update(req.params.id, dto); + res.json({ data: plan }); + } catch (error) { + next(error); + } + } + + /** + * DELETE /subscription-plans/:id + * Delete plan (soft delete) + */ + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + await this.service.delete(req.params.id); + res.status(204).send(); + } catch (error) { + next(error); + } + } + + /** + * PATCH /subscription-plans/:id/activate + * Activate plan + */ + private async activate(req: Request, res: Response, next: NextFunction): Promise { + try { + const plan = await this.service.setActive(req.params.id, true); + res.json({ data: plan }); + } catch (error) { + next(error); + } + } + + /** + * PATCH /subscription-plans/:id/deactivate + * Deactivate plan + */ + private async deactivate(req: Request, res: Response, next: NextFunction): Promise { + try { + const plan = await this.service.setActive(req.params.id, false); + res.json({ data: plan }); + } catch (error) { + next(error); + } + } + + /** + * GET /subscription-plans/:id/compare/:otherId + * Compare two plans + */ + private async comparePlans(req: Request, res: Response, next: NextFunction): Promise { + try { + const comparison = await this.service.comparePlans(req.params.id, req.params.otherId); + res.json({ data: comparison }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/billing-usage/controllers/subscriptions.controller.ts b/src/modules/billing-usage/controllers/subscriptions.controller.ts new file mode 100644 index 0000000..f8fc3a6 --- /dev/null +++ b/src/modules/billing-usage/controllers/subscriptions.controller.ts @@ -0,0 +1,232 @@ +/** + * Subscriptions Controller + * + * REST API endpoints for tenant subscription management + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { SubscriptionsService } from '../services'; +import { + CreateTenantSubscriptionDto, + UpdateTenantSubscriptionDto, + CancelSubscriptionDto, + ChangePlanDto, + SetPaymentMethodDto, +} from '../dto'; + +export class SubscriptionsController { + public router: Router; + private service: SubscriptionsService; + + constructor(dataSource: DataSource) { + this.router = Router(); + this.service = new SubscriptionsService(dataSource); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Stats (admin) + this.router.get('/stats', this.getStats.bind(this)); + + // Tenant subscription + this.router.get('/tenant/:tenantId', this.getByTenant.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.put('/:id', this.update.bind(this)); + + // Subscription actions + this.router.post('/:id/cancel', this.cancel.bind(this)); + this.router.post('/:id/reactivate', this.reactivate.bind(this)); + this.router.post('/:id/change-plan', this.changePlan.bind(this)); + this.router.post('/:id/payment-method', this.setPaymentMethod.bind(this)); + this.router.post('/:id/renew', this.renew.bind(this)); + this.router.post('/:id/suspend', this.suspend.bind(this)); + this.router.post('/:id/activate', this.activate.bind(this)); + + // Alerts/expiring + this.router.get('/expiring', this.getExpiring.bind(this)); + this.router.get('/trials-ending', this.getTrialsEnding.bind(this)); + } + + /** + * GET /subscriptions/stats + * Get subscription statistics + */ + private async getStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const stats = await this.service.getStats(); + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + /** + * GET /subscriptions/tenant/:tenantId + * Get subscription by tenant ID + */ + private async getByTenant(req: Request, res: Response, next: NextFunction): Promise { + try { + const subscription = await this.service.findByTenantId(req.params.tenantId); + + if (!subscription) { + res.status(404).json({ error: 'Subscription not found' }); + return; + } + + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscriptions + * Create new subscription + */ + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: CreateTenantSubscriptionDto = req.body; + const subscription = await this.service.create(dto); + res.status(201).json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * PUT /subscriptions/:id + * Update subscription + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: UpdateTenantSubscriptionDto = req.body; + const subscription = await this.service.update(req.params.id, dto); + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscriptions/:id/cancel + * Cancel subscription + */ + private async cancel(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: CancelSubscriptionDto = req.body; + const subscription = await this.service.cancel(req.params.id, dto); + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscriptions/:id/reactivate + * Reactivate cancelled subscription + */ + private async reactivate(req: Request, res: Response, next: NextFunction): Promise { + try { + const subscription = await this.service.reactivate(req.params.id); + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscriptions/:id/change-plan + * Change subscription plan + */ + private async changePlan(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: ChangePlanDto = req.body; + const subscription = await this.service.changePlan(req.params.id, dto); + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscriptions/:id/payment-method + * Set payment method + */ + private async setPaymentMethod(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: SetPaymentMethodDto = req.body; + const subscription = await this.service.setPaymentMethod(req.params.id, dto); + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscriptions/:id/renew + * Renew subscription + */ + private async renew(req: Request, res: Response, next: NextFunction): Promise { + try { + const subscription = await this.service.renew(req.params.id); + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscriptions/:id/suspend + * Suspend subscription + */ + private async suspend(req: Request, res: Response, next: NextFunction): Promise { + try { + const subscription = await this.service.suspend(req.params.id); + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * POST /subscriptions/:id/activate + * Activate subscription + */ + private async activate(req: Request, res: Response, next: NextFunction): Promise { + try { + const subscription = await this.service.activate(req.params.id); + res.json({ data: subscription }); + } catch (error) { + next(error); + } + } + + /** + * GET /subscriptions/expiring + * Get subscriptions expiring soon + */ + private async getExpiring(req: Request, res: Response, next: NextFunction): Promise { + try { + const days = parseInt(req.query.days as string) || 7; + const subscriptions = await this.service.findExpiringSoon(days); + res.json({ data: subscriptions }); + } catch (error) { + next(error); + } + } + + /** + * GET /subscriptions/trials-ending + * Get trials ending soon + */ + private async getTrialsEnding(req: Request, res: Response, next: NextFunction): Promise { + try { + const days = parseInt(req.query.days as string) || 3; + const subscriptions = await this.service.findTrialsEndingSoon(days); + res.json({ data: subscriptions }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/billing-usage/controllers/usage.controller.ts b/src/modules/billing-usage/controllers/usage.controller.ts new file mode 100644 index 0000000..b9088c9 --- /dev/null +++ b/src/modules/billing-usage/controllers/usage.controller.ts @@ -0,0 +1,173 @@ +/** + * Usage Controller + * + * REST API endpoints for usage tracking + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { UsageTrackingService } from '../services'; +import { RecordUsageDto, UpdateUsageDto, IncrementUsageDto, UsageMetrics } from '../dto'; + +export class UsageController { + public router: Router; + private service: UsageTrackingService; + + constructor(dataSource: DataSource) { + this.router = Router(); + this.service = new UsageTrackingService(dataSource); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Current usage + this.router.get('/tenant/:tenantId/current', this.getCurrentUsage.bind(this)); + this.router.get('/tenant/:tenantId/summary', this.getUsageSummary.bind(this)); + this.router.get('/tenant/:tenantId/limits', this.checkLimits.bind(this)); + + // Usage history + this.router.get('/tenant/:tenantId/history', this.getUsageHistory.bind(this)); + this.router.get('/tenant/:tenantId/report', this.getUsageReport.bind(this)); + + // Record usage + this.router.post('/', this.recordUsage.bind(this)); + this.router.put('/:id', this.updateUsage.bind(this)); + this.router.post('/increment', this.incrementMetric.bind(this)); + } + + /** + * GET /usage/tenant/:tenantId/current + * Get current usage for tenant + */ + private async getCurrentUsage(req: Request, res: Response, next: NextFunction): Promise { + try { + const usage = await this.service.getCurrentUsage(req.params.tenantId); + res.json({ data: usage }); + } catch (error) { + next(error); + } + } + + /** + * GET /usage/tenant/:tenantId/summary + * Get usage summary with limits + */ + private async getUsageSummary(req: Request, res: Response, next: NextFunction): Promise { + try { + const summary = await this.service.getUsageSummary(req.params.tenantId); + res.json({ data: summary }); + } catch (error) { + next(error); + } + } + + /** + * GET /usage/tenant/:tenantId/limits + * Check if tenant exceeds limits + */ + private async checkLimits(req: Request, res: Response, next: NextFunction): Promise { + try { + const limits = await this.service.checkLimits(req.params.tenantId); + res.json({ data: limits }); + } catch (error) { + next(error); + } + } + + /** + * GET /usage/tenant/:tenantId/history + * Get usage history + */ + private async getUsageHistory(req: Request, res: Response, next: NextFunction): Promise { + try { + const { startDate, endDate } = req.query; + + if (!startDate || !endDate) { + res.status(400).json({ error: 'startDate and endDate are required' }); + return; + } + + const history = await this.service.getUsageHistory( + req.params.tenantId, + new Date(startDate as string), + new Date(endDate as string) + ); + + res.json({ data: history }); + } catch (error) { + next(error); + } + } + + /** + * GET /usage/tenant/:tenantId/report + * Get usage report + */ + private async getUsageReport(req: Request, res: Response, next: NextFunction): Promise { + try { + const { startDate, endDate, granularity } = req.query; + + if (!startDate || !endDate) { + res.status(400).json({ error: 'startDate and endDate are required' }); + return; + } + + const report = await this.service.getUsageReport( + req.params.tenantId, + new Date(startDate as string), + new Date(endDate as string), + (granularity as 'daily' | 'weekly' | 'monthly') || 'monthly' + ); + + res.json({ data: report }); + } catch (error) { + next(error); + } + } + + /** + * POST /usage + * Record usage for period + */ + private async recordUsage(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: RecordUsageDto = req.body; + const usage = await this.service.recordUsage(dto); + res.status(201).json({ data: usage }); + } catch (error) { + next(error); + } + } + + /** + * PUT /usage/:id + * Update usage record + */ + private async updateUsage(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: UpdateUsageDto = req.body; + const usage = await this.service.update(req.params.id, dto); + res.json({ data: usage }); + } catch (error) { + next(error); + } + } + + /** + * POST /usage/increment + * Increment a specific metric + */ + private async incrementMetric(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: IncrementUsageDto = req.body; + await this.service.incrementMetric( + dto.tenantId, + dto.metric as keyof UsageMetrics, + dto.amount || 1 + ); + res.json({ success: true }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/billing-usage/dto/create-invoice.dto.ts b/src/modules/billing-usage/dto/create-invoice.dto.ts new file mode 100644 index 0000000..ff5435e --- /dev/null +++ b/src/modules/billing-usage/dto/create-invoice.dto.ts @@ -0,0 +1,75 @@ +/** + * Create Invoice DTO + */ + +import { InvoiceStatus, InvoiceItemType } from '../entities'; + +export class CreateInvoiceDto { + tenantId: string; + subscriptionId?: string; + invoiceDate?: Date; + periodStart: Date; + periodEnd: Date; + billingName?: string; + billingEmail?: string; + billingAddress?: Record; + taxId?: string; + dueDate: Date; + currency?: string; + notes?: string; + internalNotes?: string; + items: CreateInvoiceItemDto[]; +} + +export class CreateInvoiceItemDto { + itemType: InvoiceItemType; + description: string; + quantity: number; + unitPrice: number; + discountPercent?: number; + metadata?: Record; +} + +export class UpdateInvoiceDto { + billingName?: string; + billingEmail?: string; + billingAddress?: Record; + taxId?: string; + dueDate?: Date; + notes?: string; + internalNotes?: string; +} + +export class RecordPaymentDto { + amount: number; + paymentMethod: string; + paymentReference?: string; + paymentDate?: Date; +} + +export class VoidInvoiceDto { + reason: string; +} + +export class RefundInvoiceDto { + amount?: number; + reason: string; +} + +export class GenerateInvoiceDto { + tenantId: string; + subscriptionId: string; + periodStart: Date; + periodEnd: Date; + includeUsageCharges?: boolean; +} + +export class InvoiceFilterDto { + tenantId?: string; + status?: InvoiceStatus; + dateFrom?: Date; + dateTo?: Date; + overdue?: boolean; + limit?: number; + offset?: number; +} diff --git a/src/modules/billing-usage/dto/create-subscription-plan.dto.ts b/src/modules/billing-usage/dto/create-subscription-plan.dto.ts new file mode 100644 index 0000000..5fc1272 --- /dev/null +++ b/src/modules/billing-usage/dto/create-subscription-plan.dto.ts @@ -0,0 +1,41 @@ +/** + * Create Subscription Plan DTO + */ + +import { PlanType } from '../entities'; + +export class CreateSubscriptionPlanDto { + code: string; + name: string; + description?: string; + planType?: PlanType; + baseMonthlyPrice: number; + baseAnnualPrice?: number; + setupFee?: number; + maxUsers?: number; + maxBranches?: number; + storageGb?: number; + apiCallsMonthly?: number; + includedModules?: string[]; + includedPlatforms?: string[]; + features?: Record; + isActive?: boolean; + isPublic?: boolean; +} + +export class UpdateSubscriptionPlanDto { + name?: string; + description?: string; + baseMonthlyPrice?: number; + baseAnnualPrice?: number; + setupFee?: number; + maxUsers?: number; + maxBranches?: number; + storageGb?: number; + apiCallsMonthly?: number; + includedModules?: string[]; + includedPlatforms?: string[]; + features?: Record; + isActive?: boolean; + isPublic?: boolean; +} diff --git a/src/modules/billing-usage/dto/create-subscription.dto.ts b/src/modules/billing-usage/dto/create-subscription.dto.ts new file mode 100644 index 0000000..cdb1bac --- /dev/null +++ b/src/modules/billing-usage/dto/create-subscription.dto.ts @@ -0,0 +1,57 @@ +/** + * Create Tenant Subscription DTO + */ + +import { BillingCycle, SubscriptionStatus } from '../entities'; + +export class CreateTenantSubscriptionDto { + tenantId: string; + planId: string; + billingCycle?: BillingCycle; + currentPeriodStart?: Date; + currentPeriodEnd?: Date; + billingEmail?: string; + billingName?: string; + billingAddress?: Record; + taxId?: string; + currentPrice: number; + discountPercent?: number; + discountReason?: string; + contractedUsers?: number; + contractedBranches?: number; + autoRenew?: boolean; + // Trial + startWithTrial?: boolean; + trialDays?: number; +} + +export class UpdateTenantSubscriptionDto { + planId?: string; + billingCycle?: BillingCycle; + billingEmail?: string; + billingName?: string; + billingAddress?: Record; + taxId?: string; + currentPrice?: number; + discountPercent?: number; + discountReason?: string; + contractedUsers?: number; + contractedBranches?: number; + autoRenew?: boolean; +} + +export class CancelSubscriptionDto { + reason?: string; + cancelImmediately?: boolean; +} + +export class ChangePlanDto { + newPlanId: string; + effectiveDate?: Date; + prorateBilling?: boolean; +} + +export class SetPaymentMethodDto { + paymentMethodId: string; + paymentProvider: string; +} diff --git a/src/modules/billing-usage/dto/index.ts b/src/modules/billing-usage/dto/index.ts new file mode 100644 index 0000000..197e989 --- /dev/null +++ b/src/modules/billing-usage/dto/index.ts @@ -0,0 +1,8 @@ +/** + * Billing Usage DTOs Index + */ + +export * from './create-subscription-plan.dto'; +export * from './create-subscription.dto'; +export * from './create-invoice.dto'; +export * from './usage-tracking.dto'; diff --git a/src/modules/billing-usage/dto/usage-tracking.dto.ts b/src/modules/billing-usage/dto/usage-tracking.dto.ts new file mode 100644 index 0000000..b728664 --- /dev/null +++ b/src/modules/billing-usage/dto/usage-tracking.dto.ts @@ -0,0 +1,90 @@ +/** + * Usage Tracking DTO + */ + +export class RecordUsageDto { + tenantId: string; + periodStart: Date; + periodEnd: Date; + activeUsers?: number; + peakConcurrentUsers?: number; + usersByProfile?: Record; + usersByPlatform?: Record; + activeBranches?: number; + storageUsedGb?: number; + documentsCount?: number; + apiCalls?: number; + apiErrors?: number; + salesCount?: number; + salesAmount?: number; + invoicesGenerated?: number; + mobileSessions?: number; + offlineSyncs?: number; + paymentTransactions?: number; +} + +export class UpdateUsageDto { + activeUsers?: number; + peakConcurrentUsers?: number; + usersByProfile?: Record; + usersByPlatform?: Record; + activeBranches?: number; + storageUsedGb?: number; + documentsCount?: number; + apiCalls?: number; + apiErrors?: number; + salesCount?: number; + salesAmount?: number; + invoicesGenerated?: number; + mobileSessions?: number; + offlineSyncs?: number; + paymentTransactions?: number; +} + +export class IncrementUsageDto { + tenantId: string; + metric: keyof UsageMetrics; + amount?: number; +} + +export interface UsageMetrics { + apiCalls: number; + apiErrors: number; + salesCount: number; + salesAmount: number; + invoicesGenerated: number; + mobileSessions: number; + offlineSyncs: number; + paymentTransactions: number; + documentsCount: number; + storageUsedGb: number; +} + +export class UsageReportDto { + tenantId: string; + startDate: Date; + endDate: Date; + granularity?: 'daily' | 'weekly' | 'monthly'; +} + +export class UsageSummaryDto { + tenantId: string; + currentUsers: number; + currentBranches: number; + currentStorageGb: number; + apiCallsThisMonth: number; + salesThisMonth: number; + salesAmountThisMonth: number; + limits: { + maxUsers: number; + maxBranches: number; + maxStorageGb: number; + maxApiCalls: number; + }; + percentages: { + usersUsed: number; + branchesUsed: number; + storageUsed: number; + apiCallsUsed: number; + }; +} diff --git a/src/modules/billing-usage/entities/billing-alert.entity.ts b/src/modules/billing-usage/entities/billing-alert.entity.ts new file mode 100644 index 0000000..b6afbdc --- /dev/null +++ b/src/modules/billing-usage/entities/billing-alert.entity.ts @@ -0,0 +1,72 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type BillingAlertType = + | 'usage_limit' + | 'payment_due' + | 'payment_failed' + | 'trial_ending' + | 'subscription_ending'; + +export type AlertSeverity = 'info' | 'warning' | 'critical'; +export type AlertStatus = 'active' | 'acknowledged' | 'resolved'; + +/** + * Entidad para alertas de facturacion y limites de uso. + * Mapea a billing.billing_alerts (DDL: 05-billing-usage.sql) + */ +@Entity({ name: 'billing_alerts', schema: 'billing' }) +export class BillingAlert { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Tipo de alerta + @Index() + @Column({ name: 'alert_type', type: 'varchar', length: 30 }) + alertType: BillingAlertType; + + // Detalles + @Column({ type: 'varchar', length: 200 }) + title: string; + + @Column({ type: 'text', nullable: true }) + message: string; + + @Column({ type: 'varchar', length: 20, default: 'info' }) + severity: AlertSeverity; + + // Estado + @Index() + @Column({ type: 'varchar', length: 20, default: 'active' }) + status: AlertStatus; + + // Notificacion + @Column({ name: 'notified_at', type: 'timestamptz', nullable: true }) + notifiedAt: Date; + + @Column({ name: 'acknowledged_at', type: 'timestamptz', nullable: true }) + acknowledgedAt: Date; + + @Column({ name: 'acknowledged_by', type: 'uuid', nullable: true }) + acknowledgedBy: string; + + // Metadata + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/billing-usage/entities/index.ts b/src/modules/billing-usage/entities/index.ts new file mode 100644 index 0000000..90d2d55 --- /dev/null +++ b/src/modules/billing-usage/entities/index.ts @@ -0,0 +1,8 @@ +export { SubscriptionPlan, PlanType } from './subscription-plan.entity'; +export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity'; +export { UsageTracking } from './usage-tracking.entity'; +export { UsageEvent, EventCategory } from './usage-event.entity'; +export { Invoice, InvoiceStatus } from './invoice.entity'; +export { InvoiceItem, InvoiceItemType } from './invoice-item.entity'; +export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity'; +export { BillingAlert, BillingAlertType, AlertSeverity, AlertStatus } from './billing-alert.entity'; diff --git a/src/modules/billing-usage/entities/invoice-item.entity.ts b/src/modules/billing-usage/entities/invoice-item.entity.ts new file mode 100644 index 0000000..d8aecac --- /dev/null +++ b/src/modules/billing-usage/entities/invoice-item.entity.ts @@ -0,0 +1,65 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Invoice } from './invoice.entity'; + +export type InvoiceItemType = 'subscription' | 'user' | 'profile' | 'overage' | 'addon'; + +@Entity({ name: 'invoice_items', schema: 'billing' }) +export class InvoiceItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'invoice_id', type: 'uuid' }) + invoiceId: string; + + // Descripcion + @Column({ type: 'varchar', length: 500 }) + description: string; + + @Index() + @Column({ name: 'item_type', type: 'varchar', length: 30 }) + itemType: InvoiceItemType; + + // Cantidades + @Column({ type: 'integer', default: 1 }) + quantity: number; + + @Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 2 }) + unitPrice: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + subtotal: number; + + // Detalles adicionales + @Column({ name: 'profile_code', type: 'varchar', length: 10, nullable: true }) + profileCode: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + platform: string; + + @Column({ name: 'period_start', type: 'date', nullable: true }) + periodStart: Date; + + @Column({ name: 'period_end', type: 'date', nullable: true }) + periodEnd: Date; + + // Metadata + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relaciones + @ManyToOne(() => Invoice, (invoice) => invoice.items, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'invoice_id' }) + invoice: Invoice; +} diff --git a/src/modules/billing-usage/entities/invoice.entity.ts b/src/modules/billing-usage/entities/invoice.entity.ts new file mode 100644 index 0000000..557dadf --- /dev/null +++ b/src/modules/billing-usage/entities/invoice.entity.ts @@ -0,0 +1,121 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + OneToMany, +} from 'typeorm'; +import { InvoiceItem } from './invoice-item.entity'; + +export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'partial' | 'overdue' | 'void' | 'refunded'; + +@Entity({ name: 'invoices', schema: 'billing' }) +export class Invoice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'subscription_id', type: 'uuid', nullable: true }) + subscriptionId: string; + + // Numero de factura + @Index({ unique: true }) + @Column({ name: 'invoice_number', type: 'varchar', length: 30 }) + invoiceNumber: string; + + @Index() + @Column({ name: 'invoice_date', type: 'date' }) + invoiceDate: Date; + + // Periodo facturado + @Column({ name: 'period_start', type: 'date' }) + periodStart: Date; + + @Column({ name: 'period_end', type: 'date' }) + periodEnd: Date; + + // Cliente + @Column({ name: 'billing_name', type: 'varchar', length: 200, nullable: true }) + billingName: string; + + @Column({ name: 'billing_email', type: 'varchar', length: 255, nullable: true }) + billingEmail: string; + + @Column({ name: 'billing_address', type: 'jsonb', default: {} }) + billingAddress: Record; + + @Column({ name: 'tax_id', type: 'varchar', length: 20, nullable: true }) + taxId: string; + + // Montos + @Column({ type: 'decimal', precision: 12, scale: 2 }) + subtotal: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + total: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + // Estado + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: InvoiceStatus; + + // Fechas de pago + @Index() + @Column({ name: 'due_date', type: 'date' }) + dueDate: Date; + + @Column({ name: 'paid_at', type: 'timestamptz', nullable: true }) + paidAt: Date; + + @Column({ name: 'paid_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + paidAmount: number; + + // Detalles de pago + @Column({ name: 'payment_method', type: 'varchar', length: 30, nullable: true }) + paymentMethod: string; + + @Column({ name: 'payment_reference', type: 'varchar', length: 100, nullable: true }) + paymentReference: string; + + // CFDI (para Mexico) + @Column({ name: 'cfdi_uuid', type: 'varchar', length: 36, nullable: true }) + cfdiUuid: string; + + @Column({ name: 'cfdi_xml', type: 'text', nullable: true }) + cfdiXml: string; + + @Column({ name: 'cfdi_pdf_url', type: 'text', nullable: true }) + cfdiPdfUrl: string; + + // Metadata + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'internal_notes', type: 'text', nullable: true }) + internalNotes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relaciones + @OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: true }) + items: InvoiceItem[]; +} diff --git a/src/modules/billing-usage/entities/payment-method.entity.ts b/src/modules/billing-usage/entities/payment-method.entity.ts new file mode 100644 index 0000000..2f2e819 --- /dev/null +++ b/src/modules/billing-usage/entities/payment-method.entity.ts @@ -0,0 +1,85 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +export type PaymentProvider = 'stripe' | 'mercadopago' | 'bank_transfer'; +export type PaymentMethodType = 'card' | 'bank_account' | 'wallet'; + +/** + * Entidad para metodos de pago guardados por tenant. + * Almacena informacion tokenizada/encriptada de metodos de pago. + * Mapea a billing.payment_methods (DDL: 05-billing-usage.sql) + */ +@Entity({ name: 'payment_methods', schema: 'billing' }) +export class BillingPaymentMethod { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Proveedor + @Index() + @Column({ type: 'varchar', length: 30 }) + provider: PaymentProvider; + + // Tipo + @Column({ name: 'method_type', type: 'varchar', length: 20 }) + methodType: PaymentMethodType; + + // Datos tokenizados del proveedor + @Column({ name: 'provider_customer_id', type: 'varchar', length: 255, nullable: true }) + providerCustomerId: string; + + @Column({ name: 'provider_method_id', type: 'varchar', length: 255, nullable: true }) + providerMethodId: string; + + // Display info (no sensible) + @Column({ name: 'display_name', type: 'varchar', length: 100, nullable: true }) + displayName: string; + + @Column({ name: 'card_brand', type: 'varchar', length: 20, nullable: true }) + cardBrand: string; + + @Column({ name: 'card_last_four', type: 'varchar', length: 4, nullable: true }) + cardLastFour: string; + + @Column({ name: 'card_exp_month', type: 'integer', nullable: true }) + cardExpMonth: number; + + @Column({ name: 'card_exp_year', type: 'integer', nullable: true }) + cardExpYear: number; + + @Column({ name: 'bank_name', type: 'varchar', length: 100, nullable: true }) + bankName: string; + + @Column({ name: 'bank_last_four', type: 'varchar', length: 4, nullable: true }) + bankLastFour: string; + + // Estado + @Index() + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/billing-usage/entities/subscription-plan.entity.ts b/src/modules/billing-usage/entities/subscription-plan.entity.ts new file mode 100644 index 0000000..324e7c3 --- /dev/null +++ b/src/modules/billing-usage/entities/subscription-plan.entity.ts @@ -0,0 +1,83 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +export type PlanType = 'saas' | 'on_premise' | 'hybrid'; + +@Entity({ name: 'subscription_plans', schema: 'billing' }) +export class SubscriptionPlan { + @PrimaryGeneratedColumn('uuid') + id: string; + + // Identificacion + @Index({ unique: true }) + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Tipo + @Column({ name: 'plan_type', type: 'varchar', length: 20, default: 'saas' }) + planType: PlanType; + + // Precios base + @Column({ name: 'base_monthly_price', type: 'decimal', precision: 12, scale: 2, default: 0 }) + baseMonthlyPrice: number; + + @Column({ name: 'base_annual_price', type: 'decimal', precision: 12, scale: 2, nullable: true }) + baseAnnualPrice: number; + + @Column({ name: 'setup_fee', type: 'decimal', precision: 12, scale: 2, default: 0 }) + setupFee: number; + + // Limites base + @Column({ name: 'max_users', type: 'integer', default: 5 }) + maxUsers: number; + + @Column({ name: 'max_branches', type: 'integer', default: 1 }) + maxBranches: number; + + @Column({ name: 'storage_gb', type: 'integer', default: 10 }) + storageGb: number; + + @Column({ name: 'api_calls_monthly', type: 'integer', default: 10000 }) + apiCallsMonthly: number; + + // Modulos incluidos + @Column({ name: 'included_modules', type: 'text', array: true, default: [] }) + includedModules: string[]; + + // Plataformas incluidas + @Column({ name: 'included_platforms', type: 'text', array: true, default: ['web'] }) + includedPlatforms: string[]; + + // Features + @Column({ type: 'jsonb', default: {} }) + features: Record; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_public', type: 'boolean', default: true }) + isPublic: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/billing-usage/entities/tenant-subscription.entity.ts b/src/modules/billing-usage/entities/tenant-subscription.entity.ts new file mode 100644 index 0000000..5cdc50e --- /dev/null +++ b/src/modules/billing-usage/entities/tenant-subscription.entity.ts @@ -0,0 +1,117 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { SubscriptionPlan } from './subscription-plan.entity'; + +export type BillingCycle = 'monthly' | 'annual'; +export type SubscriptionStatus = 'trial' | 'active' | 'past_due' | 'cancelled' | 'suspended'; + +@Entity({ name: 'tenant_subscriptions', schema: 'billing' }) +@Unique(['tenantId']) +export class TenantSubscription { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'plan_id', type: 'uuid' }) + planId: string; + + // Periodo + @Column({ name: 'billing_cycle', type: 'varchar', length: 20, default: 'monthly' }) + billingCycle: BillingCycle; + + @Column({ name: 'current_period_start', type: 'timestamptz' }) + currentPeriodStart: Date; + + @Column({ name: 'current_period_end', type: 'timestamptz' }) + currentPeriodEnd: Date; + + // Estado + @Index() + @Column({ type: 'varchar', length: 20, default: 'active' }) + status: SubscriptionStatus; + + // Trial + @Column({ name: 'trial_start', type: 'timestamptz', nullable: true }) + trialStart: Date; + + @Column({ name: 'trial_end', type: 'timestamptz', nullable: true }) + trialEnd: Date; + + // Configuracion de facturacion + @Column({ name: 'billing_email', type: 'varchar', length: 255, nullable: true }) + billingEmail: string; + + @Column({ name: 'billing_name', type: 'varchar', length: 200, nullable: true }) + billingName: string; + + @Column({ name: 'billing_address', type: 'jsonb', default: {} }) + billingAddress: Record; + + @Column({ name: 'tax_id', type: 'varchar', length: 20, nullable: true }) + taxId: string; // RFC para Mexico + + // Metodo de pago + @Column({ name: 'payment_method_id', type: 'uuid', nullable: true }) + paymentMethodId: string; + + @Column({ name: 'payment_provider', type: 'varchar', length: 30, nullable: true }) + paymentProvider: string; // stripe, mercadopago, bank_transfer + + // Precios actuales + @Column({ name: 'current_price', type: 'decimal', precision: 12, scale: 2 }) + currentPrice: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_reason', type: 'varchar', length: 100, nullable: true }) + discountReason: string; + + // Uso contratado + @Column({ name: 'contracted_users', type: 'integer', nullable: true }) + contractedUsers: number; + + @Column({ name: 'contracted_branches', type: 'integer', nullable: true }) + contractedBranches: number; + + // Facturacion automatica + @Column({ name: 'auto_renew', type: 'boolean', default: true }) + autoRenew: boolean; + + @Column({ name: 'next_invoice_date', type: 'date', nullable: true }) + nextInvoiceDate: Date; + + // Cancelacion + @Column({ name: 'cancel_at_period_end', type: 'boolean', default: false }) + cancelAtPeriodEnd: boolean; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt: Date; + + @Column({ name: 'cancellation_reason', type: 'text', nullable: true }) + cancellationReason: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relaciones + @ManyToOne(() => SubscriptionPlan) + @JoinColumn({ name: 'plan_id' }) + plan: SubscriptionPlan; +} diff --git a/src/modules/billing-usage/entities/usage-event.entity.ts b/src/modules/billing-usage/entities/usage-event.entity.ts new file mode 100644 index 0000000..ab29f61 --- /dev/null +++ b/src/modules/billing-usage/entities/usage-event.entity.ts @@ -0,0 +1,73 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type EventCategory = 'user' | 'api' | 'storage' | 'transaction' | 'mobile'; + +/** + * Entidad para eventos de uso en tiempo real. + * Utilizada para calculo de billing y tracking granular. + * Mapea a billing.usage_events (DDL: 05-billing-usage.sql) + */ +@Entity({ name: 'usage_events', schema: 'billing' }) +export class UsageEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'device_id', type: 'uuid', nullable: true }) + deviceId: string; + + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + // Evento + @Index() + @Column({ name: 'event_type', type: 'varchar', length: 50 }) + eventType: string; // login, api_call, document_upload, sale, invoice, sync + + @Index() + @Column({ name: 'event_category', type: 'varchar', length: 30 }) + eventCategory: EventCategory; + + // Detalles + @Column({ name: 'profile_code', type: 'varchar', length: 10, nullable: true }) + profileCode: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + platform: string; + + @Column({ name: 'resource_id', type: 'uuid', nullable: true }) + resourceId: string; + + @Column({ name: 'resource_type', type: 'varchar', length: 50, nullable: true }) + resourceType: string; + + // Metricas + @Column({ type: 'integer', default: 1 }) + quantity: number; + + @Column({ name: 'bytes_used', type: 'bigint', default: 0 }) + bytesUsed: number; + + @Column({ name: 'duration_ms', type: 'integer', nullable: true }) + durationMs: number; + + // Metadata + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/billing-usage/entities/usage-tracking.entity.ts b/src/modules/billing-usage/entities/usage-tracking.entity.ts new file mode 100644 index 0000000..d5ad4b3 --- /dev/null +++ b/src/modules/billing-usage/entities/usage-tracking.entity.ts @@ -0,0 +1,91 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +@Entity({ name: 'usage_tracking', schema: 'billing' }) +@Unique(['tenantId', 'periodStart']) +export class UsageTracking { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Periodo + @Index() + @Column({ name: 'period_start', type: 'date' }) + periodStart: Date; + + @Column({ name: 'period_end', type: 'date' }) + periodEnd: Date; + + // Usuarios + @Column({ name: 'active_users', type: 'integer', default: 0 }) + activeUsers: number; + + @Column({ name: 'peak_concurrent_users', type: 'integer', default: 0 }) + peakConcurrentUsers: number; + + // Por perfil + @Column({ name: 'users_by_profile', type: 'jsonb', default: {} }) + usersByProfile: Record; // {"ADM": 2, "VNT": 5, "ALM": 3} + + // Por plataforma + @Column({ name: 'users_by_platform', type: 'jsonb', default: {} }) + usersByPlatform: Record; // {"web": 8, "mobile": 5, "desktop": 0} + + // Sucursales + @Column({ name: 'active_branches', type: 'integer', default: 0 }) + activeBranches: number; + + // Storage + @Column({ name: 'storage_used_gb', type: 'decimal', precision: 10, scale: 2, default: 0 }) + storageUsedGb: number; + + @Column({ name: 'documents_count', type: 'integer', default: 0 }) + documentsCount: number; + + // API + @Column({ name: 'api_calls', type: 'integer', default: 0 }) + apiCalls: number; + + @Column({ name: 'api_errors', type: 'integer', default: 0 }) + apiErrors: number; + + // Transacciones + @Column({ name: 'sales_count', type: 'integer', default: 0 }) + salesCount: number; + + @Column({ name: 'sales_amount', type: 'decimal', precision: 14, scale: 2, default: 0 }) + salesAmount: number; + + @Column({ name: 'invoices_generated', type: 'integer', default: 0 }) + invoicesGenerated: number; + + // Mobile + @Column({ name: 'mobile_sessions', type: 'integer', default: 0 }) + mobileSessions: number; + + @Column({ name: 'offline_syncs', type: 'integer', default: 0 }) + offlineSyncs: number; + + @Column({ name: 'payment_transactions', type: 'integer', default: 0 }) + paymentTransactions: number; + + // Calculado + @Column({ name: 'total_billable_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + totalBillableAmount: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/billing-usage/index.ts b/src/modules/billing-usage/index.ts new file mode 100644 index 0000000..08dc806 --- /dev/null +++ b/src/modules/billing-usage/index.ts @@ -0,0 +1,18 @@ +/** + * Billing Usage Module Index + */ + +// Module +export { BillingUsageModule, BillingUsageModuleOptions } from './billing-usage.module'; + +// Entities +export * from './entities'; + +// DTOs +export * from './dto'; + +// Services +export * from './services'; + +// Controllers +export * from './controllers'; diff --git a/src/modules/billing-usage/services/index.ts b/src/modules/billing-usage/services/index.ts new file mode 100644 index 0000000..c0d4392 --- /dev/null +++ b/src/modules/billing-usage/services/index.ts @@ -0,0 +1,8 @@ +/** + * Billing Usage Services Index + */ + +export { SubscriptionPlansService } from './subscription-plans.service'; +export { SubscriptionsService } from './subscriptions.service'; +export { UsageTrackingService } from './usage-tracking.service'; +export { InvoicesService } from './invoices.service'; diff --git a/src/modules/billing-usage/services/invoices.service.ts b/src/modules/billing-usage/services/invoices.service.ts new file mode 100644 index 0000000..117fc8e --- /dev/null +++ b/src/modules/billing-usage/services/invoices.service.ts @@ -0,0 +1,471 @@ +/** + * Invoices Service + * + * Service for managing invoices + */ + +import { Repository, DataSource, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { Invoice, InvoiceItem, InvoiceStatus, TenantSubscription, UsageTracking } from '../entities'; +import { + CreateInvoiceDto, + UpdateInvoiceDto, + RecordPaymentDto, + VoidInvoiceDto, + RefundInvoiceDto, + GenerateInvoiceDto, + InvoiceFilterDto, +} from '../dto'; + +export class InvoicesService { + private invoiceRepository: Repository; + private itemRepository: Repository; + private subscriptionRepository: Repository; + private usageRepository: Repository; + + constructor(private dataSource: DataSource) { + this.invoiceRepository = dataSource.getRepository(Invoice); + this.itemRepository = dataSource.getRepository(InvoiceItem); + this.subscriptionRepository = dataSource.getRepository(TenantSubscription); + this.usageRepository = dataSource.getRepository(UsageTracking); + } + + /** + * Create invoice manually + */ + async create(dto: CreateInvoiceDto): Promise { + const invoiceNumber = await this.generateInvoiceNumber(); + + // Calculate totals + let subtotal = 0; + for (const item of dto.items) { + const itemTotal = item.quantity * item.unitPrice; + const discount = itemTotal * ((item.discountPercent || 0) / 100); + subtotal += itemTotal - discount; + } + + const taxAmount = subtotal * 0.16; // 16% IVA for Mexico + const total = subtotal + taxAmount; + + const invoice = this.invoiceRepository.create({ + tenantId: dto.tenantId, + subscriptionId: dto.subscriptionId, + invoiceNumber, + invoiceDate: dto.invoiceDate || new Date(), + periodStart: dto.periodStart, + periodEnd: dto.periodEnd, + billingName: dto.billingName, + billingEmail: dto.billingEmail, + billingAddress: dto.billingAddress || {}, + taxId: dto.taxId, + subtotal, + taxAmount, + discountAmount: 0, + total, + currency: dto.currency || 'MXN', + status: 'draft', + dueDate: dto.dueDate, + notes: dto.notes, + internalNotes: dto.internalNotes, + }); + + const savedInvoice = await this.invoiceRepository.save(invoice); + + // Create items + for (const itemDto of dto.items) { + const itemTotal = itemDto.quantity * itemDto.unitPrice; + const discount = itemTotal * ((itemDto.discountPercent || 0) / 100); + + const item = this.itemRepository.create({ + invoiceId: savedInvoice.id, + itemType: itemDto.itemType, + description: itemDto.description, + quantity: itemDto.quantity, + unitPrice: itemDto.unitPrice, + discountPercent: itemDto.discountPercent || 0, + subtotal: itemTotal - discount, + metadata: itemDto.metadata || {}, + }); + + await this.itemRepository.save(item); + } + + return this.findById(savedInvoice.id) as Promise; + } + + /** + * Generate invoice automatically from subscription + */ + async generateFromSubscription(dto: GenerateInvoiceDto): Promise { + const subscription = await this.subscriptionRepository.findOne({ + where: { id: dto.subscriptionId }, + relations: ['plan'], + }); + + if (!subscription) { + throw new Error('Subscription not found'); + } + + const items: CreateInvoiceDto['items'] = []; + + // Base subscription fee + items.push({ + itemType: 'subscription', + description: `Suscripcion ${subscription.plan.name} - ${subscription.billingCycle === 'annual' ? 'Anual' : 'Mensual'}`, + quantity: 1, + unitPrice: Number(subscription.currentPrice), + }); + + // Include usage charges if requested + if (dto.includeUsageCharges) { + const usage = await this.usageRepository.findOne({ + where: { + tenantId: dto.tenantId, + periodStart: dto.periodStart, + }, + }); + + if (usage) { + // Extra users + const extraUsers = Math.max( + 0, + usage.activeUsers - (subscription.contractedUsers || subscription.plan.maxUsers) + ); + if (extraUsers > 0) { + items.push({ + itemType: 'overage', + description: `Usuarios adicionales (${extraUsers})`, + quantity: extraUsers, + unitPrice: 10, // $10 per extra user + metadata: { metric: 'extra_users' }, + }); + } + + // Extra branches + const extraBranches = Math.max( + 0, + usage.activeBranches - (subscription.contractedBranches || subscription.plan.maxBranches) + ); + if (extraBranches > 0) { + items.push({ + itemType: 'overage', + description: `Sucursales adicionales (${extraBranches})`, + quantity: extraBranches, + unitPrice: 20, // $20 per extra branch + metadata: { metric: 'extra_branches' }, + }); + } + + // Extra storage + const extraStorageGb = Math.max( + 0, + Number(usage.storageUsedGb) - subscription.plan.storageGb + ); + if (extraStorageGb > 0) { + items.push({ + itemType: 'overage', + description: `Almacenamiento adicional (${extraStorageGb} GB)`, + quantity: Math.ceil(extraStorageGb), + unitPrice: 0.5, // $0.50 per GB + metadata: { metric: 'extra_storage' }, + }); + } + } + } + + // Calculate due date (15 days from invoice date) + const dueDate = new Date(); + dueDate.setDate(dueDate.getDate() + 15); + + return this.create({ + tenantId: dto.tenantId, + subscriptionId: dto.subscriptionId, + periodStart: dto.periodStart, + periodEnd: dto.periodEnd, + billingName: subscription.billingName, + billingEmail: subscription.billingEmail, + billingAddress: subscription.billingAddress, + taxId: subscription.taxId, + dueDate, + items, + }); + } + + /** + * Find invoice by ID + */ + async findById(id: string): Promise { + return this.invoiceRepository.findOne({ + where: { id }, + relations: ['items'], + }); + } + + /** + * Find invoice by number + */ + async findByNumber(invoiceNumber: string): Promise { + return this.invoiceRepository.findOne({ + where: { invoiceNumber }, + relations: ['items'], + }); + } + + /** + * Find invoices with filters + */ + async findAll(filter: InvoiceFilterDto): Promise<{ data: Invoice[]; total: number }> { + const query = this.invoiceRepository + .createQueryBuilder('invoice') + .leftJoinAndSelect('invoice.items', 'items'); + + if (filter.tenantId) { + query.andWhere('invoice.tenantId = :tenantId', { tenantId: filter.tenantId }); + } + + if (filter.status) { + query.andWhere('invoice.status = :status', { status: filter.status }); + } + + if (filter.dateFrom) { + query.andWhere('invoice.invoiceDate >= :dateFrom', { dateFrom: filter.dateFrom }); + } + + if (filter.dateTo) { + query.andWhere('invoice.invoiceDate <= :dateTo', { dateTo: filter.dateTo }); + } + + if (filter.overdue) { + query.andWhere('invoice.dueDate < :now', { now: new Date() }); + query.andWhere("invoice.status IN ('sent', 'partial')"); + } + + const total = await query.getCount(); + + query.orderBy('invoice.invoiceDate', 'DESC'); + + if (filter.limit) { + query.take(filter.limit); + } + + if (filter.offset) { + query.skip(filter.offset); + } + + const data = await query.getMany(); + + return { data, total }; + } + + /** + * Update invoice + */ + async update(id: string, dto: UpdateInvoiceDto): Promise { + const invoice = await this.findById(id); + if (!invoice) { + throw new Error('Invoice not found'); + } + + if (invoice.status !== 'draft') { + throw new Error('Only draft invoices can be updated'); + } + + Object.assign(invoice, dto); + return this.invoiceRepository.save(invoice); + } + + /** + * Send invoice + */ + async send(id: string): Promise { + const invoice = await this.findById(id); + if (!invoice) { + throw new Error('Invoice not found'); + } + + if (invoice.status !== 'draft') { + throw new Error('Only draft invoices can be sent'); + } + + invoice.status = 'sent'; + // TODO: Send email notification to billing email + + return this.invoiceRepository.save(invoice); + } + + /** + * Record payment + */ + async recordPayment(id: string, dto: RecordPaymentDto): Promise { + const invoice = await this.findById(id); + if (!invoice) { + throw new Error('Invoice not found'); + } + + if (invoice.status === 'void' || invoice.status === 'refunded') { + throw new Error('Cannot record payment for voided or refunded invoice'); + } + + const newPaidAmount = Number(invoice.paidAmount) + dto.amount; + const total = Number(invoice.total); + + invoice.paidAmount = newPaidAmount; + invoice.paymentMethod = dto.paymentMethod; + invoice.paymentReference = dto.paymentReference; + + if (newPaidAmount >= total) { + invoice.status = 'paid'; + invoice.paidAt = dto.paymentDate || new Date(); + } else if (newPaidAmount > 0) { + invoice.status = 'partial'; + } + + return this.invoiceRepository.save(invoice); + } + + /** + * Void invoice + */ + async void(id: string, dto: VoidInvoiceDto): Promise { + const invoice = await this.findById(id); + if (!invoice) { + throw new Error('Invoice not found'); + } + + if (invoice.status === 'paid' || invoice.status === 'refunded') { + throw new Error('Cannot void paid or refunded invoice'); + } + + invoice.status = 'void'; + invoice.internalNotes = `${invoice.internalNotes || ''}\n\nVoided: ${dto.reason}`.trim(); + + return this.invoiceRepository.save(invoice); + } + + /** + * Refund invoice + */ + async refund(id: string, dto: RefundInvoiceDto): Promise { + const invoice = await this.findById(id); + if (!invoice) { + throw new Error('Invoice not found'); + } + + if (invoice.status !== 'paid' && invoice.status !== 'partial') { + throw new Error('Only paid invoices can be refunded'); + } + + const refundAmount = dto.amount || Number(invoice.paidAmount); + + if (refundAmount > Number(invoice.paidAmount)) { + throw new Error('Refund amount cannot exceed paid amount'); + } + + invoice.status = 'refunded'; + invoice.internalNotes = + `${invoice.internalNotes || ''}\n\nRefunded: ${refundAmount} - ${dto.reason}`.trim(); + + // TODO: Process actual refund through payment provider + + return this.invoiceRepository.save(invoice); + } + + /** + * Mark overdue invoices + */ + async markOverdueInvoices(): Promise { + const now = new Date(); + + const result = await this.invoiceRepository + .createQueryBuilder() + .update(Invoice) + .set({ status: 'overdue' }) + .where("status IN ('sent', 'partial')") + .andWhere('dueDate < :now', { now }) + .execute(); + + return result.affected || 0; + } + + /** + * Get invoice statistics + */ + async getStats(tenantId?: string): Promise<{ + total: number; + byStatus: Record; + totalRevenue: number; + pendingAmount: number; + overdueAmount: number; + }> { + const query = this.invoiceRepository.createQueryBuilder('invoice'); + + if (tenantId) { + query.where('invoice.tenantId = :tenantId', { tenantId }); + } + + const invoices = await query.getMany(); + + const byStatus: Record = { + draft: 0, + sent: 0, + paid: 0, + partial: 0, + overdue: 0, + void: 0, + refunded: 0, + }; + + let totalRevenue = 0; + let pendingAmount = 0; + let overdueAmount = 0; + const now = new Date(); + + for (const invoice of invoices) { + byStatus[invoice.status]++; + + if (invoice.status === 'paid') { + totalRevenue += Number(invoice.paidAmount); + } + + if (invoice.status === 'sent' || invoice.status === 'partial') { + const pending = Number(invoice.total) - Number(invoice.paidAmount); + pendingAmount += pending; + + if (invoice.dueDate < now) { + overdueAmount += pending; + } + } + } + + return { + total: invoices.length, + byStatus, + totalRevenue, + pendingAmount, + overdueAmount, + }; + } + + /** + * Generate unique invoice number + */ + private async generateInvoiceNumber(): Promise { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + + // Get last invoice number for this month + const lastInvoice = await this.invoiceRepository + .createQueryBuilder('invoice') + .where('invoice.invoiceNumber LIKE :pattern', { pattern: `INV-${year}${month}%` }) + .orderBy('invoice.invoiceNumber', 'DESC') + .getOne(); + + let sequence = 1; + if (lastInvoice) { + const lastSequence = parseInt(lastInvoice.invoiceNumber.slice(-4), 10); + sequence = lastSequence + 1; + } + + return `INV-${year}${month}-${String(sequence).padStart(4, '0')}`; + } +} diff --git a/src/modules/billing-usage/services/subscription-plans.service.ts b/src/modules/billing-usage/services/subscription-plans.service.ts new file mode 100644 index 0000000..c4c8dbd --- /dev/null +++ b/src/modules/billing-usage/services/subscription-plans.service.ts @@ -0,0 +1,200 @@ +/** + * Subscription Plans Service + * + * Service for managing subscription plans + */ + +import { Repository, DataSource } from 'typeorm'; +import { SubscriptionPlan, PlanType } from '../entities'; +import { CreateSubscriptionPlanDto, UpdateSubscriptionPlanDto } from '../dto'; + +export class SubscriptionPlansService { + private planRepository: Repository; + + constructor(private dataSource: DataSource) { + this.planRepository = dataSource.getRepository(SubscriptionPlan); + } + + /** + * Create a new subscription plan + */ + async create(dto: CreateSubscriptionPlanDto): Promise { + // Check if code already exists + const existing = await this.planRepository.findOne({ + where: { code: dto.code }, + }); + + if (existing) { + throw new Error(`Plan with code ${dto.code} already exists`); + } + + const plan = this.planRepository.create({ + code: dto.code, + name: dto.name, + description: dto.description, + planType: dto.planType || 'saas', + baseMonthlyPrice: dto.baseMonthlyPrice, + baseAnnualPrice: dto.baseAnnualPrice, + setupFee: dto.setupFee || 0, + maxUsers: dto.maxUsers || 5, + maxBranches: dto.maxBranches || 1, + storageGb: dto.storageGb || 10, + apiCallsMonthly: dto.apiCallsMonthly || 10000, + includedModules: dto.includedModules || [], + includedPlatforms: dto.includedPlatforms || ['web'], + features: dto.features || {}, + isActive: dto.isActive !== false, + isPublic: dto.isPublic !== false, + }); + + return this.planRepository.save(plan); + } + + /** + * Find all plans + */ + async findAll(options?: { + isActive?: boolean; + isPublic?: boolean; + planType?: PlanType; + }): Promise { + const query = this.planRepository.createQueryBuilder('plan'); + + if (options?.isActive !== undefined) { + query.andWhere('plan.isActive = :isActive', { isActive: options.isActive }); + } + + if (options?.isPublic !== undefined) { + query.andWhere('plan.isPublic = :isPublic', { isPublic: options.isPublic }); + } + + if (options?.planType) { + query.andWhere('plan.planType = :planType', { planType: options.planType }); + } + + return query.orderBy('plan.baseMonthlyPrice', 'ASC').getMany(); + } + + /** + * Find public plans (for pricing page) + */ + async findPublicPlans(): Promise { + return this.findAll({ isActive: true, isPublic: true }); + } + + /** + * Find plan by ID + */ + async findById(id: string): Promise { + return this.planRepository.findOne({ where: { id } }); + } + + /** + * Find plan by code + */ + async findByCode(code: string): Promise { + return this.planRepository.findOne({ where: { code } }); + } + + /** + * Update a plan + */ + async update(id: string, dto: UpdateSubscriptionPlanDto): Promise { + const plan = await this.findById(id); + if (!plan) { + throw new Error('Plan not found'); + } + + Object.assign(plan, dto); + return this.planRepository.save(plan); + } + + /** + * Soft delete a plan + */ + async delete(id: string): Promise { + const plan = await this.findById(id); + if (!plan) { + throw new Error('Plan not found'); + } + + // Check if plan has active subscriptions + const subscriptionCount = await this.dataSource + .createQueryBuilder() + .select('COUNT(*)') + .from('billing.tenant_subscriptions', 'ts') + .where('ts.plan_id = :planId', { planId: id }) + .andWhere("ts.status IN ('active', 'trial')") + .getRawOne(); + + if (parseInt(subscriptionCount.count) > 0) { + throw new Error('Cannot delete plan with active subscriptions'); + } + + await this.planRepository.softDelete(id); + } + + /** + * Activate/deactivate a plan + */ + async setActive(id: string, isActive: boolean): Promise { + return this.update(id, { isActive }); + } + + /** + * Compare two plans + */ + async comparePlans( + planId1: string, + planId2: string + ): Promise<{ + plan1: SubscriptionPlan; + plan2: SubscriptionPlan; + differences: Record; + }> { + const [plan1, plan2] = await Promise.all([ + this.findById(planId1), + this.findById(planId2), + ]); + + if (!plan1 || !plan2) { + throw new Error('One or both plans not found'); + } + + const fieldsToCompare = [ + 'baseMonthlyPrice', + 'baseAnnualPrice', + 'maxUsers', + 'maxBranches', + 'storageGb', + 'apiCallsMonthly', + ]; + + const differences: Record = {}; + + for (const field of fieldsToCompare) { + if ((plan1 as any)[field] !== (plan2 as any)[field]) { + differences[field] = { + plan1: (plan1 as any)[field], + plan2: (plan2 as any)[field], + }; + } + } + + // Compare included modules + const modules1 = new Set(plan1.includedModules); + const modules2 = new Set(plan2.includedModules); + const modulesDiff = { + onlyInPlan1: plan1.includedModules.filter((m) => !modules2.has(m)), + onlyInPlan2: plan2.includedModules.filter((m) => !modules1.has(m)), + }; + if (modulesDiff.onlyInPlan1.length > 0 || modulesDiff.onlyInPlan2.length > 0) { + differences.includedModules = { + plan1: modulesDiff.onlyInPlan1, + plan2: modulesDiff.onlyInPlan2, + }; + } + + return { plan1, plan2, differences }; + } +} diff --git a/src/modules/billing-usage/services/subscriptions.service.ts b/src/modules/billing-usage/services/subscriptions.service.ts new file mode 100644 index 0000000..c693f05 --- /dev/null +++ b/src/modules/billing-usage/services/subscriptions.service.ts @@ -0,0 +1,384 @@ +/** + * Subscriptions Service + * + * Service for managing tenant subscriptions + */ + +import { Repository, DataSource } from 'typeorm'; +import { + TenantSubscription, + SubscriptionPlan, + BillingCycle, + SubscriptionStatus, +} from '../entities'; +import { + CreateTenantSubscriptionDto, + UpdateTenantSubscriptionDto, + CancelSubscriptionDto, + ChangePlanDto, + SetPaymentMethodDto, +} from '../dto'; + +export class SubscriptionsService { + private subscriptionRepository: Repository; + private planRepository: Repository; + + constructor(private dataSource: DataSource) { + this.subscriptionRepository = dataSource.getRepository(TenantSubscription); + this.planRepository = dataSource.getRepository(SubscriptionPlan); + } + + /** + * Create a new subscription + */ + async create(dto: CreateTenantSubscriptionDto): Promise { + // Check if tenant already has a subscription + const existing = await this.subscriptionRepository.findOne({ + where: { tenantId: dto.tenantId }, + }); + + if (existing) { + throw new Error('Tenant already has a subscription'); + } + + // Validate plan exists + const plan = await this.planRepository.findOne({ where: { id: dto.planId } }); + if (!plan) { + throw new Error('Plan not found'); + } + + const now = new Date(); + const currentPeriodStart = dto.currentPeriodStart || now; + const currentPeriodEnd = + dto.currentPeriodEnd || this.calculatePeriodEnd(currentPeriodStart, dto.billingCycle || 'monthly'); + + const subscription = this.subscriptionRepository.create({ + tenantId: dto.tenantId, + planId: dto.planId, + billingCycle: dto.billingCycle || 'monthly', + currentPeriodStart, + currentPeriodEnd, + status: dto.startWithTrial ? 'trial' : 'active', + billingEmail: dto.billingEmail, + billingName: dto.billingName, + billingAddress: dto.billingAddress || {}, + taxId: dto.taxId, + currentPrice: dto.currentPrice, + discountPercent: dto.discountPercent || 0, + discountReason: dto.discountReason, + contractedUsers: dto.contractedUsers || plan.maxUsers, + contractedBranches: dto.contractedBranches || plan.maxBranches, + autoRenew: dto.autoRenew !== false, + nextInvoiceDate: currentPeriodEnd, + }); + + // Set trial dates if starting with trial + if (dto.startWithTrial) { + subscription.trialStart = now; + subscription.trialEnd = new Date(now.getTime() + (dto.trialDays || 14) * 24 * 60 * 60 * 1000); + } + + return this.subscriptionRepository.save(subscription); + } + + /** + * Find subscription by tenant ID + */ + async findByTenantId(tenantId: string): Promise { + return this.subscriptionRepository.findOne({ + where: { tenantId }, + relations: ['plan'], + }); + } + + /** + * Find subscription by ID + */ + async findById(id: string): Promise { + return this.subscriptionRepository.findOne({ + where: { id }, + relations: ['plan'], + }); + } + + /** + * Update subscription + */ + async update(id: string, dto: UpdateTenantSubscriptionDto): Promise { + const subscription = await this.findById(id); + if (!subscription) { + throw new Error('Subscription not found'); + } + + // If changing plan, validate it exists + if (dto.planId && dto.planId !== subscription.planId) { + const plan = await this.planRepository.findOne({ where: { id: dto.planId } }); + if (!plan) { + throw new Error('Plan not found'); + } + } + + Object.assign(subscription, dto); + return this.subscriptionRepository.save(subscription); + } + + /** + * Cancel subscription + */ + async cancel(id: string, dto: CancelSubscriptionDto): Promise { + const subscription = await this.findById(id); + if (!subscription) { + throw new Error('Subscription not found'); + } + + if (subscription.status === 'cancelled') { + throw new Error('Subscription is already cancelled'); + } + + subscription.cancellationReason = dto.reason; + subscription.cancelledAt = new Date(); + + if (dto.cancelImmediately) { + subscription.status = 'cancelled'; + } else { + subscription.cancelAtPeriodEnd = true; + subscription.autoRenew = false; + } + + return this.subscriptionRepository.save(subscription); + } + + /** + * Reactivate cancelled subscription + */ + async reactivate(id: string): Promise { + const subscription = await this.findById(id); + if (!subscription) { + throw new Error('Subscription not found'); + } + + if (subscription.status !== 'cancelled' && !subscription.cancelAtPeriodEnd) { + throw new Error('Subscription is not cancelled'); + } + + subscription.status = 'active'; + subscription.cancelAtPeriodEnd = false; + subscription.cancellationReason = null as any; + subscription.cancelledAt = null as any; + subscription.autoRenew = true; + + return this.subscriptionRepository.save(subscription); + } + + /** + * Change subscription plan + */ + async changePlan(id: string, dto: ChangePlanDto): Promise { + const subscription = await this.findById(id); + if (!subscription) { + throw new Error('Subscription not found'); + } + + const newPlan = await this.planRepository.findOne({ where: { id: dto.newPlanId } }); + if (!newPlan) { + throw new Error('New plan not found'); + } + + // Calculate new price + const newPrice = + subscription.billingCycle === 'annual' && newPlan.baseAnnualPrice + ? newPlan.baseAnnualPrice + : newPlan.baseMonthlyPrice; + + // Apply existing discount if any + const discountedPrice = newPrice * (1 - (subscription.discountPercent || 0) / 100); + + subscription.planId = dto.newPlanId; + subscription.currentPrice = discountedPrice; + subscription.contractedUsers = newPlan.maxUsers; + subscription.contractedBranches = newPlan.maxBranches; + + // If effective immediately and prorate, calculate adjustment + // This would typically create a credit/debit memo + + return this.subscriptionRepository.save(subscription); + } + + /** + * Set payment method + */ + async setPaymentMethod(id: string, dto: SetPaymentMethodDto): Promise { + const subscription = await this.findById(id); + if (!subscription) { + throw new Error('Subscription not found'); + } + + subscription.paymentMethodId = dto.paymentMethodId; + subscription.paymentProvider = dto.paymentProvider; + + return this.subscriptionRepository.save(subscription); + } + + /** + * Renew subscription (for periodic billing) + */ + async renew(id: string): Promise { + const subscription = await this.findById(id); + if (!subscription) { + throw new Error('Subscription not found'); + } + + if (!subscription.autoRenew) { + throw new Error('Subscription auto-renew is disabled'); + } + + if (subscription.cancelAtPeriodEnd) { + subscription.status = 'cancelled'; + return this.subscriptionRepository.save(subscription); + } + + // Calculate new period + const newPeriodStart = subscription.currentPeriodEnd; + const newPeriodEnd = this.calculatePeriodEnd(newPeriodStart, subscription.billingCycle); + + subscription.currentPeriodStart = newPeriodStart; + subscription.currentPeriodEnd = newPeriodEnd; + subscription.nextInvoiceDate = newPeriodEnd; + + // Reset trial status if was in trial + if (subscription.status === 'trial') { + subscription.status = 'active'; + } + + return this.subscriptionRepository.save(subscription); + } + + /** + * Mark subscription as past due + */ + async markPastDue(id: string): Promise { + return this.updateStatus(id, 'past_due'); + } + + /** + * Suspend subscription + */ + async suspend(id: string): Promise { + return this.updateStatus(id, 'suspended'); + } + + /** + * Activate subscription (from suspended or past_due) + */ + async activate(id: string): Promise { + return this.updateStatus(id, 'active'); + } + + /** + * Update subscription status + */ + private async updateStatus(id: string, status: SubscriptionStatus): Promise { + const subscription = await this.findById(id); + if (!subscription) { + throw new Error('Subscription not found'); + } + + subscription.status = status; + return this.subscriptionRepository.save(subscription); + } + + /** + * Find subscriptions expiring soon + */ + async findExpiringSoon(days: number = 7): Promise { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + days); + + return this.subscriptionRepository + .createQueryBuilder('sub') + .leftJoinAndSelect('sub.plan', 'plan') + .where('sub.currentPeriodEnd <= :futureDate', { futureDate }) + .andWhere("sub.status IN ('active', 'trial')") + .andWhere('sub.cancelAtPeriodEnd = false') + .orderBy('sub.currentPeriodEnd', 'ASC') + .getMany(); + } + + /** + * Find subscriptions with trials ending soon + */ + async findTrialsEndingSoon(days: number = 3): Promise { + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + days); + + return this.subscriptionRepository + .createQueryBuilder('sub') + .leftJoinAndSelect('sub.plan', 'plan') + .where("sub.status = 'trial'") + .andWhere('sub.trialEnd <= :futureDate', { futureDate }) + .orderBy('sub.trialEnd', 'ASC') + .getMany(); + } + + /** + * Calculate period end date based on billing cycle + */ + private calculatePeriodEnd(start: Date, cycle: BillingCycle): Date { + const end = new Date(start); + if (cycle === 'annual') { + end.setFullYear(end.getFullYear() + 1); + } else { + end.setMonth(end.getMonth() + 1); + } + return end; + } + + /** + * Get subscription statistics + */ + async getStats(): Promise<{ + total: number; + byStatus: Record; + byPlan: Record; + totalMRR: number; + totalARR: number; + }> { + const subscriptions = await this.subscriptionRepository.find({ + relations: ['plan'], + }); + + const byStatus: Record = { + trial: 0, + active: 0, + past_due: 0, + cancelled: 0, + suspended: 0, + }; + + const byPlan: Record = {}; + let totalMRR = 0; + + for (const sub of subscriptions) { + byStatus[sub.status]++; + + const planCode = sub.plan?.code || 'unknown'; + byPlan[planCode] = (byPlan[planCode] || 0) + 1; + + if (sub.status === 'active' || sub.status === 'trial') { + const monthlyPrice = + sub.billingCycle === 'annual' + ? Number(sub.currentPrice) / 12 + : Number(sub.currentPrice); + totalMRR += monthlyPrice; + } + } + + return { + total: subscriptions.length, + byStatus, + byPlan, + totalMRR, + totalARR: totalMRR * 12, + }; + } +} diff --git a/src/modules/billing-usage/services/usage-tracking.service.ts b/src/modules/billing-usage/services/usage-tracking.service.ts new file mode 100644 index 0000000..3095bbe --- /dev/null +++ b/src/modules/billing-usage/services/usage-tracking.service.ts @@ -0,0 +1,381 @@ +/** + * Usage Tracking Service + * + * Service for tracking and reporting usage metrics + */ + +import { Repository, DataSource, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { UsageTracking, TenantSubscription, SubscriptionPlan } from '../entities'; +import { RecordUsageDto, UpdateUsageDto, UsageMetrics, UsageSummaryDto } from '../dto'; + +export class UsageTrackingService { + private usageRepository: Repository; + private subscriptionRepository: Repository; + private planRepository: Repository; + + constructor(private dataSource: DataSource) { + this.usageRepository = dataSource.getRepository(UsageTracking); + this.subscriptionRepository = dataSource.getRepository(TenantSubscription); + this.planRepository = dataSource.getRepository(SubscriptionPlan); + } + + /** + * Record usage for a period + */ + async recordUsage(dto: RecordUsageDto): Promise { + // Check if record exists for this tenant/period + const existing = await this.usageRepository.findOne({ + where: { + tenantId: dto.tenantId, + periodStart: dto.periodStart, + }, + }); + + if (existing) { + // Update existing record + return this.update(existing.id, dto); + } + + const usage = this.usageRepository.create({ + tenantId: dto.tenantId, + periodStart: dto.periodStart, + periodEnd: dto.periodEnd, + activeUsers: dto.activeUsers || 0, + peakConcurrentUsers: dto.peakConcurrentUsers || 0, + usersByProfile: dto.usersByProfile || {}, + usersByPlatform: dto.usersByPlatform || {}, + activeBranches: dto.activeBranches || 0, + storageUsedGb: dto.storageUsedGb || 0, + documentsCount: dto.documentsCount || 0, + apiCalls: dto.apiCalls || 0, + apiErrors: dto.apiErrors || 0, + salesCount: dto.salesCount || 0, + salesAmount: dto.salesAmount || 0, + invoicesGenerated: dto.invoicesGenerated || 0, + mobileSessions: dto.mobileSessions || 0, + offlineSyncs: dto.offlineSyncs || 0, + paymentTransactions: dto.paymentTransactions || 0, + }); + + // Calculate billable amount + usage.totalBillableAmount = await this.calculateBillableAmount(dto.tenantId, usage); + + return this.usageRepository.save(usage); + } + + /** + * Update usage record + */ + async update(id: string, dto: UpdateUsageDto): Promise { + const usage = await this.usageRepository.findOne({ where: { id } }); + if (!usage) { + throw new Error('Usage record not found'); + } + + Object.assign(usage, dto); + usage.totalBillableAmount = await this.calculateBillableAmount(usage.tenantId, usage); + + return this.usageRepository.save(usage); + } + + /** + * Increment a specific metric + */ + async incrementMetric( + tenantId: string, + metric: keyof UsageMetrics, + amount: number = 1 + ): Promise { + const currentPeriod = this.getCurrentPeriodDates(); + + let usage = await this.usageRepository.findOne({ + where: { + tenantId, + periodStart: currentPeriod.start, + }, + }); + + if (!usage) { + usage = await this.recordUsage({ + tenantId, + periodStart: currentPeriod.start, + periodEnd: currentPeriod.end, + }); + } + + // Increment the specific metric + (usage as any)[metric] = ((usage as any)[metric] || 0) + amount; + + await this.usageRepository.save(usage); + } + + /** + * Get current usage for tenant + */ + async getCurrentUsage(tenantId: string): Promise { + const currentPeriod = this.getCurrentPeriodDates(); + + return this.usageRepository.findOne({ + where: { + tenantId, + periodStart: currentPeriod.start, + }, + }); + } + + /** + * Get usage history for tenant + */ + async getUsageHistory( + tenantId: string, + startDate: Date, + endDate: Date + ): Promise { + return this.usageRepository.find({ + where: { + tenantId, + periodStart: MoreThanOrEqual(startDate), + periodEnd: LessThanOrEqual(endDate), + }, + order: { periodStart: 'DESC' }, + }); + } + + /** + * Get usage summary with limits comparison + */ + async getUsageSummary(tenantId: string): Promise { + const subscription = await this.subscriptionRepository.findOne({ + where: { tenantId }, + relations: ['plan'], + }); + + if (!subscription) { + throw new Error('Subscription not found'); + } + + const currentUsage = await this.getCurrentUsage(tenantId); + const plan = subscription.plan; + + const summary: UsageSummaryDto = { + tenantId, + currentUsers: currentUsage?.activeUsers || 0, + currentBranches: currentUsage?.activeBranches || 0, + currentStorageGb: Number(currentUsage?.storageUsedGb || 0), + apiCallsThisMonth: currentUsage?.apiCalls || 0, + salesThisMonth: currentUsage?.salesCount || 0, + salesAmountThisMonth: Number(currentUsage?.salesAmount || 0), + limits: { + maxUsers: subscription.contractedUsers || plan.maxUsers, + maxBranches: subscription.contractedBranches || plan.maxBranches, + maxStorageGb: plan.storageGb, + maxApiCalls: plan.apiCallsMonthly, + }, + percentages: { + usersUsed: 0, + branchesUsed: 0, + storageUsed: 0, + apiCallsUsed: 0, + }, + }; + + // Calculate percentages + summary.percentages.usersUsed = + Math.round((summary.currentUsers / summary.limits.maxUsers) * 100); + summary.percentages.branchesUsed = + Math.round((summary.currentBranches / summary.limits.maxBranches) * 100); + summary.percentages.storageUsed = + Math.round((summary.currentStorageGb / summary.limits.maxStorageGb) * 100); + summary.percentages.apiCallsUsed = + Math.round((summary.apiCallsThisMonth / summary.limits.maxApiCalls) * 100); + + return summary; + } + + /** + * Check if tenant exceeds limits + */ + async checkLimits(tenantId: string): Promise<{ + exceeds: boolean; + violations: string[]; + warnings: string[]; + }> { + const summary = await this.getUsageSummary(tenantId); + const violations: string[] = []; + const warnings: string[] = []; + + // Check hard limits + if (summary.currentUsers > summary.limits.maxUsers) { + violations.push(`Users: ${summary.currentUsers}/${summary.limits.maxUsers}`); + } + + if (summary.currentBranches > summary.limits.maxBranches) { + violations.push(`Branches: ${summary.currentBranches}/${summary.limits.maxBranches}`); + } + + if (summary.currentStorageGb > summary.limits.maxStorageGb) { + violations.push( + `Storage: ${summary.currentStorageGb}GB/${summary.limits.maxStorageGb}GB` + ); + } + + // Check warnings (80% threshold) + if (summary.percentages.usersUsed >= 80 && summary.percentages.usersUsed < 100) { + warnings.push(`Users at ${summary.percentages.usersUsed}% capacity`); + } + + if (summary.percentages.branchesUsed >= 80 && summary.percentages.branchesUsed < 100) { + warnings.push(`Branches at ${summary.percentages.branchesUsed}% capacity`); + } + + if (summary.percentages.storageUsed >= 80 && summary.percentages.storageUsed < 100) { + warnings.push(`Storage at ${summary.percentages.storageUsed}% capacity`); + } + + if (summary.percentages.apiCallsUsed >= 80 && summary.percentages.apiCallsUsed < 100) { + warnings.push(`API calls at ${summary.percentages.apiCallsUsed}% capacity`); + } + + return { + exceeds: violations.length > 0, + violations, + warnings, + }; + } + + /** + * Get usage report + */ + async getUsageReport( + tenantId: string, + startDate: Date, + endDate: Date, + granularity: 'daily' | 'weekly' | 'monthly' = 'monthly' + ): Promise<{ + tenantId: string; + startDate: Date; + endDate: Date; + granularity: string; + data: UsageTracking[]; + totals: { + apiCalls: number; + salesCount: number; + salesAmount: number; + mobileSessions: number; + paymentTransactions: number; + }; + averages: { + activeUsers: number; + activeBranches: number; + storageUsedGb: number; + }; + }> { + const data = await this.getUsageHistory(tenantId, startDate, endDate); + + // Calculate totals + const totals = { + apiCalls: 0, + salesCount: 0, + salesAmount: 0, + mobileSessions: 0, + paymentTransactions: 0, + }; + + let totalUsers = 0; + let totalBranches = 0; + let totalStorage = 0; + + for (const record of data) { + totals.apiCalls += record.apiCalls; + totals.salesCount += record.salesCount; + totals.salesAmount += Number(record.salesAmount); + totals.mobileSessions += record.mobileSessions; + totals.paymentTransactions += record.paymentTransactions; + + totalUsers += record.activeUsers; + totalBranches += record.activeBranches; + totalStorage += Number(record.storageUsedGb); + } + + const count = data.length || 1; + + return { + tenantId, + startDate, + endDate, + granularity, + data, + totals, + averages: { + activeUsers: Math.round(totalUsers / count), + activeBranches: Math.round(totalBranches / count), + storageUsedGb: Math.round((totalStorage / count) * 100) / 100, + }, + }; + } + + /** + * Calculate billable amount based on usage + */ + private async calculateBillableAmount( + tenantId: string, + usage: UsageTracking + ): Promise { + const subscription = await this.subscriptionRepository.findOne({ + where: { tenantId }, + relations: ['plan'], + }); + + if (!subscription) { + return 0; + } + + let billableAmount = Number(subscription.currentPrice); + + // Add overage charges if applicable + const plan = subscription.plan; + + // Extra users + const extraUsers = Math.max(0, usage.activeUsers - (subscription.contractedUsers || plan.maxUsers)); + if (extraUsers > 0) { + // Assume $10 per extra user per month + billableAmount += extraUsers * 10; + } + + // Extra branches + const extraBranches = Math.max( + 0, + usage.activeBranches - (subscription.contractedBranches || plan.maxBranches) + ); + if (extraBranches > 0) { + // Assume $20 per extra branch per month + billableAmount += extraBranches * 20; + } + + // Extra storage + const extraStorageGb = Math.max(0, Number(usage.storageUsedGb) - plan.storageGb); + if (extraStorageGb > 0) { + // Assume $0.50 per extra GB + billableAmount += extraStorageGb * 0.5; + } + + // Extra API calls + const extraApiCalls = Math.max(0, usage.apiCalls - plan.apiCallsMonthly); + if (extraApiCalls > 0) { + // Assume $0.001 per extra API call + billableAmount += extraApiCalls * 0.001; + } + + return billableAmount; + } + + /** + * Get current period dates (first and last day of current month) + */ + private getCurrentPeriodDates(): { start: Date; end: Date } { + const now = new Date(); + const start = new Date(now.getFullYear(), now.getMonth(), 1); + const end = new Date(now.getFullYear(), now.getMonth() + 1, 0); + return { start, end }; + } +} diff --git a/src/modules/biometrics/entities/biometric-credential.entity.ts b/src/modules/biometrics/entities/biometric-credential.entity.ts new file mode 100644 index 0000000..c77fbce --- /dev/null +++ b/src/modules/biometrics/entities/biometric-credential.entity.ts @@ -0,0 +1,81 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { Device, BiometricType } from './device.entity'; + +@Entity({ name: 'biometric_credentials', schema: 'auth' }) +@Unique(['deviceId', 'credentialId']) +export class BiometricCredential { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + // Tipo de biometrico + @Index() + @Column({ name: 'biometric_type', type: 'varchar', length: 50 }) + biometricType: BiometricType; + + // Credencial (public key para WebAuthn/FIDO2) + @Column({ name: 'credential_id', type: 'text' }) + credentialId: string; + + @Column({ name: 'public_key', type: 'text' }) + publicKey: string; + + @Column({ type: 'varchar', length: 20, default: 'ES256' }) + algorithm: string; + + // Metadata + @Column({ name: 'credential_name', type: 'varchar', length: 100, nullable: true }) + credentialName: string; // "Huella indice derecho", "Face ID iPhone" + + @Column({ name: 'is_primary', type: 'boolean', default: false }) + isPrimary: boolean; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: Date; + + @Column({ name: 'use_count', type: 'integer', default: 0 }) + useCount: number; + + // Seguridad + @Column({ name: 'failed_attempts', type: 'integer', default: 0 }) + failedAttempts: number; + + @Column({ name: 'locked_until', type: 'timestamptz', nullable: true }) + lockedUntil: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + // Relaciones + @ManyToOne(() => Device, (device) => device.biometricCredentials, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'device_id' }) + device: Device; +} diff --git a/src/modules/biometrics/entities/device-activity-log.entity.ts b/src/modules/biometrics/entities/device-activity-log.entity.ts new file mode 100644 index 0000000..e245f45 --- /dev/null +++ b/src/modules/biometrics/entities/device-activity-log.entity.ts @@ -0,0 +1,50 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type ActivityType = 'login' | 'logout' | 'biometric_auth' | 'location_update' | 'app_open' | 'app_close'; +export type ActivityStatus = 'success' | 'failed' | 'blocked'; + +@Entity({ name: 'device_activity_log', schema: 'auth' }) +export class DeviceActivityLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + // Actividad + @Index() + @Column({ name: 'activity_type', type: 'varchar', length: 50 }) + activityType: ActivityType; + + @Column({ name: 'activity_status', type: 'varchar', length: 20 }) + activityStatus: ActivityStatus; + + // Detalles + @Column({ type: 'jsonb', default: {} }) + details: Record; + + // Ubicacion + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/biometrics/entities/device-session.entity.ts b/src/modules/biometrics/entities/device-session.entity.ts new file mode 100644 index 0000000..c94ecb4 --- /dev/null +++ b/src/modules/biometrics/entities/device-session.entity.ts @@ -0,0 +1,84 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Device } from './device.entity'; + +export type AuthMethod = 'password' | 'biometric' | 'oauth' | 'mfa'; + +@Entity({ name: 'device_sessions', schema: 'auth' }) +export class DeviceSession { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + // Tokens + @Index() + @Column({ name: 'access_token_hash', type: 'varchar', length: 255 }) + accessTokenHash: string; + + @Column({ name: 'refresh_token_hash', type: 'varchar', length: 255, nullable: true }) + refreshTokenHash: string; + + // Metodo de autenticacion + @Column({ name: 'auth_method', type: 'varchar', length: 50 }) + authMethod: AuthMethod; + + // Validez + @Column({ name: 'issued_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + issuedAt: Date; + + @Index() + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt: Date; + + @Column({ name: 'refresh_expires_at', type: 'timestamptz', nullable: true }) + refreshExpiresAt: Date; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt: Date; + + @Column({ name: 'revoked_reason', type: 'varchar', length: 100, nullable: true }) + revokedReason: string; + + // Ubicacion + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress: string; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string; + + @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relaciones + @ManyToOne(() => Device, (device) => device.sessions, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'device_id' }) + device: Device; +} diff --git a/src/modules/biometrics/entities/device.entity.ts b/src/modules/biometrics/entities/device.entity.ts new file mode 100644 index 0000000..6ee5295 --- /dev/null +++ b/src/modules/biometrics/entities/device.entity.ts @@ -0,0 +1,121 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + OneToMany, + Unique, +} from 'typeorm'; +import { BiometricCredential } from './biometric-credential.entity'; +import { DeviceSession } from './device-session.entity'; + +export type DevicePlatform = 'ios' | 'android' | 'web' | 'desktop'; +export type BiometricType = 'fingerprint' | 'face_id' | 'face_recognition' | 'iris'; + +@Entity({ name: 'devices', schema: 'auth' }) +@Unique(['userId', 'deviceUuid']) +export class Device { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + // Identificacion del dispositivo + @Index() + @Column({ name: 'device_uuid', type: 'varchar', length: 100 }) + deviceUuid: string; + + @Column({ name: 'device_name', type: 'varchar', length: 100, nullable: true }) + deviceName: string; + + @Column({ name: 'device_model', type: 'varchar', length: 100, nullable: true }) + deviceModel: string; + + @Column({ name: 'device_brand', type: 'varchar', length: 50, nullable: true }) + deviceBrand: string; + + // Plataforma + @Index() + @Column({ type: 'varchar', length: 20 }) + platform: DevicePlatform; + + @Column({ name: 'platform_version', type: 'varchar', length: 20, nullable: true }) + platformVersion: string; + + @Column({ name: 'app_version', type: 'varchar', length: 20, nullable: true }) + appVersion: string; + + // Estado + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_trusted', type: 'boolean', default: false }) + isTrusted: boolean; + + @Column({ name: 'trust_level', type: 'integer', default: 0 }) + trustLevel: number; // 0=none, 1=low, 2=medium, 3=high + + // Biometricos habilitados + @Column({ name: 'biometric_enabled', type: 'boolean', default: false }) + biometricEnabled: boolean; + + @Column({ name: 'biometric_type', type: 'varchar', length: 50, nullable: true }) + biometricType: BiometricType; + + // Push notifications + @Column({ name: 'push_token', type: 'text', nullable: true }) + pushToken: string; + + @Column({ name: 'push_token_updated_at', type: 'timestamptz', nullable: true }) + pushTokenUpdatedAt: Date; + + // Ubicacion ultima conocida + @Column({ name: 'last_latitude', type: 'decimal', precision: 10, scale: 8, nullable: true }) + lastLatitude: number; + + @Column({ name: 'last_longitude', type: 'decimal', precision: 11, scale: 8, nullable: true }) + lastLongitude: number; + + @Column({ name: 'last_location_at', type: 'timestamptz', nullable: true }) + lastLocationAt: Date; + + // Seguridad + @Column({ name: 'last_ip_address', type: 'inet', nullable: true }) + lastIpAddress: string; + + @Column({ name: 'last_user_agent', type: 'text', nullable: true }) + lastUserAgent: string; + + // Registro + @Column({ name: 'first_seen_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + firstSeenAt: Date; + + @Column({ name: 'last_seen_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + lastSeenAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + // Relaciones + @OneToMany(() => BiometricCredential, (credential) => credential.device) + biometricCredentials: BiometricCredential[]; + + @OneToMany(() => DeviceSession, (session) => session.device) + sessions: DeviceSession[]; +} diff --git a/src/modules/biometrics/entities/index.ts b/src/modules/biometrics/entities/index.ts new file mode 100644 index 0000000..17eca5d --- /dev/null +++ b/src/modules/biometrics/entities/index.ts @@ -0,0 +1,4 @@ +export { Device, DevicePlatform, BiometricType } from './device.entity'; +export { BiometricCredential } from './biometric-credential.entity'; +export { DeviceSession, AuthMethod } from './device-session.entity'; +export { DeviceActivityLog, ActivityType, ActivityStatus } from './device-activity-log.entity'; diff --git a/src/modules/branches/branches.module.ts b/src/modules/branches/branches.module.ts new file mode 100644 index 0000000..31c6748 --- /dev/null +++ b/src/modules/branches/branches.module.ts @@ -0,0 +1,48 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { BranchesService } from './services'; +import { BranchesController } from './controllers'; +import { Branch, UserBranchAssignment, BranchSchedule, BranchPaymentTerminal } from './entities'; + +export interface BranchesModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class BranchesModule { + public router: Router; + public branchesService: BranchesService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: BranchesModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const branchRepository = this.dataSource.getRepository(Branch); + const assignmentRepository = this.dataSource.getRepository(UserBranchAssignment); + const scheduleRepository = this.dataSource.getRepository(BranchSchedule); + const terminalRepository = this.dataSource.getRepository(BranchPaymentTerminal); + + this.branchesService = new BranchesService( + branchRepository, + assignmentRepository, + scheduleRepository, + terminalRepository + ); + } + + private initializeRoutes(): void { + const branchesController = new BranchesController(this.branchesService); + this.router.use(`${this.basePath}/branches`, branchesController.router); + } + + static getEntities(): Function[] { + return [Branch, UserBranchAssignment, BranchSchedule, BranchPaymentTerminal]; + } +} diff --git a/src/modules/branches/controllers/branches.controller.ts b/src/modules/branches/controllers/branches.controller.ts new file mode 100644 index 0000000..40d896e --- /dev/null +++ b/src/modules/branches/controllers/branches.controller.ts @@ -0,0 +1,364 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { BranchesService } from '../services/branches.service'; +import { CreateBranchDto, UpdateBranchDto, AssignUserToBranchDto, CreateBranchScheduleDto } from '../dto'; + +export class BranchesController { + public router: Router; + + constructor(private readonly branchesService: BranchesService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Branch CRUD + this.router.get('/', this.findAll.bind(this)); + this.router.get('/hierarchy', this.getHierarchy.bind(this)); + this.router.get('/main', this.getMainBranch.bind(this)); + this.router.get('/nearby', this.findNearbyBranches.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.get('/code/:code', this.findByCode.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + this.router.post('/:id/set-main', this.setAsMainBranch.bind(this)); + + // Hierarchy + this.router.get('/:id/children', this.getChildren.bind(this)); + this.router.get('/:id/parents', this.getParents.bind(this)); + + // User Assignments + this.router.post('/assign', this.assignUser.bind(this)); + this.router.delete('/assign/:userId/:branchId', this.unassignUser.bind(this)); + this.router.get('/user/:userId', this.getUserBranches.bind(this)); + this.router.get('/user/:userId/primary', this.getPrimaryBranch.bind(this)); + this.router.get('/:id/users', this.getBranchUsers.bind(this)); + + // Geofencing + this.router.post('/validate-geofence', this.validateGeofence.bind(this)); + + // Schedules + this.router.get('/:id/schedules', this.getSchedules.bind(this)); + this.router.post('/:id/schedules', this.addSchedule.bind(this)); + this.router.get('/:id/is-open', this.isOpenNow.bind(this)); + } + + // ============================================ + // BRANCH CRUD + // ============================================ + + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { search, branchType, isActive, parentId, limit, offset } = req.query; + + const result = await this.branchesService.findAll(tenantId, { + search: search as string, + branchType: branchType as string, + isActive: isActive ? isActive === 'true' : undefined, + parentId: parentId as string, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const branch = await this.branchesService.findOne(id); + + if (!branch) { + res.status(404).json({ error: 'Branch not found' }); + return; + } + + res.json({ data: branch }); + } catch (error) { + next(error); + } + } + + private async findByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { code } = req.params; + const branch = await this.branchesService.findByCode(tenantId, code); + + if (!branch) { + res.status(404).json({ error: 'Branch not found' }); + return; + } + + res.json({ data: branch }); + } catch (error) { + next(error); + } + } + + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const dto: CreateBranchDto = req.body; + + const branch = await this.branchesService.create(tenantId, dto, userId); + res.status(201).json({ data: branch }); + } catch (error) { + next(error); + } + } + + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const userId = req.headers['x-user-id'] as string; + const dto: UpdateBranchDto = req.body; + + const branch = await this.branchesService.update(id, dto, userId); + + if (!branch) { + res.status(404).json({ error: 'Branch not found' }); + return; + } + + res.json({ data: branch }); + } catch (error) { + next(error); + } + } + + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deleted = await this.branchesService.delete(id); + + if (!deleted) { + res.status(404).json({ error: 'Branch not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // HIERARCHY + // ============================================ + + private async getHierarchy(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const hierarchy = await this.branchesService.getHierarchy(tenantId); + res.json({ data: hierarchy }); + } catch (error) { + next(error); + } + } + + private async getChildren(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { recursive } = req.query; + const children = await this.branchesService.getChildren(id, recursive === 'true'); + res.json({ data: children }); + } catch (error) { + next(error); + } + } + + private async getParents(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const parents = await this.branchesService.getParents(id); + res.json({ data: parents }); + } catch (error) { + next(error); + } + } + + // ============================================ + // USER ASSIGNMENTS + // ============================================ + + private async assignUser(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const assignedBy = req.headers['x-user-id'] as string; + const dto: AssignUserToBranchDto = req.body; + + const assignment = await this.branchesService.assignUser(tenantId, dto, assignedBy); + res.status(201).json({ data: assignment }); + } catch (error) { + next(error); + } + } + + private async unassignUser(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId, branchId } = req.params; + const unassigned = await this.branchesService.unassignUser(userId, branchId); + + if (!unassigned) { + res.status(404).json({ error: 'Assignment not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async getUserBranches(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId } = req.params; + const branches = await this.branchesService.getUserBranches(userId); + res.json({ data: branches }); + } catch (error) { + next(error); + } + } + + private async getPrimaryBranch(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId } = req.params; + const branch = await this.branchesService.getPrimaryBranch(userId); + + if (!branch) { + res.status(404).json({ error: 'No primary branch found' }); + return; + } + + res.json({ data: branch }); + } catch (error) { + next(error); + } + } + + private async getBranchUsers(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const users = await this.branchesService.getBranchUsers(id); + res.json({ data: users }); + } catch (error) { + next(error); + } + } + + // ============================================ + // GEOFENCING + // ============================================ + + private async validateGeofence(req: Request, res: Response, next: NextFunction): Promise { + try { + const { branchId, latitude, longitude } = req.body; + + const result = await this.branchesService.validateGeofence(branchId, latitude, longitude); + res.json({ data: result }); + } catch (error) { + next(error); + } + } + + private async findNearbyBranches(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { latitude, longitude, radius } = req.query; + + if (!latitude || !longitude) { + res.status(400).json({ error: 'Latitude and longitude are required' }); + return; + } + + const branches = await this.branchesService.findNearbyBranches( + tenantId, + parseFloat(latitude as string), + parseFloat(longitude as string), + radius ? parseInt(radius as string, 10) : undefined + ); + + res.json({ data: branches }); + } catch (error) { + next(error); + } + } + + // ============================================ + // SCHEDULES + // ============================================ + + private async getSchedules(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const schedules = await this.branchesService.getSchedules(id); + res.json({ data: schedules }); + } catch (error) { + next(error); + } + } + + private async addSchedule(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const dto: CreateBranchScheduleDto = req.body; + + const schedule = await this.branchesService.addSchedule(id, dto); + res.status(201).json({ data: schedule }); + } catch (error) { + next(error); + } + } + + private async isOpenNow(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const isOpen = await this.branchesService.isOpenNow(id); + res.json({ data: { isOpen } }); + } catch (error) { + next(error); + } + } + + // ============================================ + // MAIN BRANCH + // ============================================ + + private async getMainBranch(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const branch = await this.branchesService.getMainBranch(tenantId); + + if (!branch) { + res.status(404).json({ error: 'No main branch found' }); + return; + } + + res.json({ data: branch }); + } catch (error) { + next(error); + } + } + + private async setAsMainBranch(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const branch = await this.branchesService.setAsMainBranch(id); + + if (!branch) { + res.status(404).json({ error: 'Branch not found' }); + return; + } + + res.json({ data: branch }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/branches/controllers/index.ts b/src/modules/branches/controllers/index.ts new file mode 100644 index 0000000..9bb0086 --- /dev/null +++ b/src/modules/branches/controllers/index.ts @@ -0,0 +1 @@ +export { BranchesController } from './branches.controller'; diff --git a/src/modules/branches/dto/branch-schedule.dto.ts b/src/modules/branches/dto/branch-schedule.dto.ts new file mode 100644 index 0000000..a922a69 --- /dev/null +++ b/src/modules/branches/dto/branch-schedule.dto.ts @@ -0,0 +1,100 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsEnum, + MaxLength, + IsDateString, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { ScheduleType } from '../entities/branch-schedule.entity'; + +class ShiftDto { + @IsString() + @MaxLength(50) + name: string; + + @IsString() + start: string; + + @IsString() + end: string; +} + +export class CreateBranchScheduleDto { + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(['regular', 'holiday', 'special']) + scheduleType?: ScheduleType; + + @IsOptional() + @IsNumber() + dayOfWeek?: number; // 0=domingo, 1=lunes, ..., 6=sabado + + @IsOptional() + @IsDateString() + specificDate?: string; + + @IsString() + openTime: string; + + @IsString() + closeTime: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ShiftDto) + shifts?: ShiftDto[]; +} + +export class UpdateBranchScheduleDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(['regular', 'holiday', 'special']) + scheduleType?: ScheduleType; + + @IsOptional() + @IsNumber() + dayOfWeek?: number; + + @IsOptional() + @IsDateString() + specificDate?: string; + + @IsOptional() + @IsString() + openTime?: string; + + @IsOptional() + @IsString() + closeTime?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ShiftDto) + shifts?: ShiftDto[]; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/modules/branches/dto/create-branch.dto.ts b/src/modules/branches/dto/create-branch.dto.ts new file mode 100644 index 0000000..afee637 --- /dev/null +++ b/src/modules/branches/dto/create-branch.dto.ts @@ -0,0 +1,265 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsObject, + IsUUID, + IsArray, + MaxLength, + MinLength, + IsEnum, + IsLatitude, + IsLongitude, +} from 'class-validator'; +import { BranchType } from '../entities/branch.entity'; + +export class CreateBranchDto { + @IsString() + @MinLength(2) + @MaxLength(20) + code: string; + + @IsString() + @MinLength(2) + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + @MaxLength(50) + shortName?: string; + + @IsOptional() + @IsEnum(['headquarters', 'regional', 'store', 'warehouse', 'office', 'factory']) + branchType?: BranchType; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsUUID() + managerId?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + addressLine1?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + addressLine2?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + state?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + postalCode?: string; + + @IsOptional() + @IsString() + @MaxLength(3) + country?: string; + + @IsOptional() + @IsNumber() + latitude?: number; + + @IsOptional() + @IsNumber() + longitude?: number; + + @IsOptional() + @IsNumber() + geofenceRadius?: number; + + @IsOptional() + @IsBoolean() + geofenceEnabled?: boolean; + + @IsOptional() + @IsString() + @MaxLength(50) + timezone?: string; + + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + @IsOptional() + @IsBoolean() + isMain?: boolean; + + @IsOptional() + @IsObject() + operatingHours?: Record; + + @IsOptional() + @IsObject() + settings?: Record; +} + +export class UpdateBranchDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + shortName?: string; + + @IsOptional() + @IsEnum(['headquarters', 'regional', 'store', 'warehouse', 'office', 'factory']) + branchType?: BranchType; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsUUID() + managerId?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + addressLine1?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + addressLine2?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + state?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + postalCode?: string; + + @IsOptional() + @IsString() + @MaxLength(3) + country?: string; + + @IsOptional() + @IsNumber() + latitude?: number; + + @IsOptional() + @IsNumber() + longitude?: number; + + @IsOptional() + @IsNumber() + geofenceRadius?: number; + + @IsOptional() + @IsBoolean() + geofenceEnabled?: boolean; + + @IsOptional() + @IsString() + @MaxLength(50) + timezone?: string; + + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isMain?: boolean; + + @IsOptional() + @IsObject() + operatingHours?: Record; + + @IsOptional() + @IsObject() + settings?: Record; +} + +export class AssignUserToBranchDto { + @IsUUID() + userId: string; + + @IsUUID() + branchId: string; + + @IsOptional() + @IsEnum(['primary', 'secondary', 'temporary', 'floating']) + assignmentType?: string; + + @IsOptional() + @IsEnum(['manager', 'supervisor', 'staff']) + branchRole?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + permissions?: string[]; + + @IsOptional() + @IsString() + validUntil?: string; +} + +export class ValidateGeofenceDto { + @IsUUID() + branchId: string; + + @IsNumber() + latitude: number; + + @IsNumber() + longitude: number; +} diff --git a/src/modules/branches/dto/index.ts b/src/modules/branches/dto/index.ts new file mode 100644 index 0000000..2c6b163 --- /dev/null +++ b/src/modules/branches/dto/index.ts @@ -0,0 +1,11 @@ +export { + CreateBranchDto, + UpdateBranchDto, + AssignUserToBranchDto, + ValidateGeofenceDto, +} from './create-branch.dto'; + +export { + CreateBranchScheduleDto, + UpdateBranchScheduleDto, +} from './branch-schedule.dto'; diff --git a/src/modules/branches/entities/branch-inventory-settings.entity.ts b/src/modules/branches/entities/branch-inventory-settings.entity.ts new file mode 100644 index 0000000..6e769ff --- /dev/null +++ b/src/modules/branches/entities/branch-inventory-settings.entity.ts @@ -0,0 +1,63 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Branch } from './branch.entity'; + +/** + * Configuración de inventario por sucursal. + * Mapea a core.branch_inventory_settings (DDL: 03-core-branches.sql) + */ +@Entity({ name: 'branch_inventory_settings', schema: 'core' }) +export class BranchInventorySettings { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @OneToOne(() => Branch, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'branch_id' }) + branch: Branch; + + // Almacén asociado (referencia externa a inventory.warehouses) + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId: string; + + // Configuración de stock + @Column({ name: 'default_stock_min', type: 'integer', default: 0 }) + defaultStockMin: number; + + @Column({ name: 'default_stock_max', type: 'integer', default: 1000 }) + defaultStockMax: number; + + @Column({ name: 'auto_reorder_enabled', type: 'boolean', default: false }) + autoReorderEnabled: boolean; + + // Configuración de precios (referencia externa a sales.price_lists) + @Column({ name: 'price_list_id', type: 'uuid', nullable: true }) + priceListId: string; + + @Column({ name: 'allow_price_override', type: 'boolean', default: false }) + allowPriceOverride: boolean; + + @Column({ name: 'max_discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + maxDiscountPercent: number; + + // Configuración de impuestos + @Column({ name: 'tax_config', type: 'jsonb', default: {} }) + taxConfig: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/branches/entities/branch-payment-terminal.entity.ts b/src/modules/branches/entities/branch-payment-terminal.entity.ts new file mode 100644 index 0000000..1af5393 --- /dev/null +++ b/src/modules/branches/entities/branch-payment-terminal.entity.ts @@ -0,0 +1,77 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { Branch } from './branch.entity'; + +export type TerminalProvider = 'clip' | 'mercadopago' | 'stripe'; +export type HealthStatus = 'healthy' | 'degraded' | 'offline' | 'unknown'; + +@Entity({ name: 'branch_payment_terminals', schema: 'core' }) +@Unique(['branchId', 'terminalProvider', 'terminalId']) +export class BranchPaymentTerminal { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + // Terminal + @Index() + @Column({ name: 'terminal_provider', type: 'varchar', length: 30 }) + terminalProvider: TerminalProvider; + + @Column({ name: 'terminal_id', type: 'varchar', length: 100 }) + terminalId: string; + + @Column({ name: 'terminal_name', type: 'varchar', length: 100, nullable: true }) + terminalName: string; + + // Credenciales (encriptadas) + @Column({ type: 'jsonb', default: {} }) + credentials: Record; + + // Configuracion + @Column({ name: 'is_primary', type: 'boolean', default: false }) + isPrimary: boolean; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + // Limites + @Column({ name: 'daily_limit', type: 'decimal', precision: 12, scale: 2, nullable: true }) + dailyLimit: number; + + @Column({ name: 'transaction_limit', type: 'decimal', precision: 12, scale: 2, nullable: true }) + transactionLimit: number; + + // Ultima actividad + @Column({ name: 'last_transaction_at', type: 'timestamptz', nullable: true }) + lastTransactionAt: Date; + + @Column({ name: 'last_health_check_at', type: 'timestamptz', nullable: true }) + lastHealthCheckAt: Date; + + @Column({ name: 'health_status', type: 'varchar', length: 20, default: 'unknown' }) + healthStatus: HealthStatus; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relaciones + @ManyToOne(() => Branch, (branch) => branch.paymentTerminals, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'branch_id' }) + branch: Branch; +} diff --git a/src/modules/branches/entities/branch-schedule.entity.ts b/src/modules/branches/entities/branch-schedule.entity.ts new file mode 100644 index 0000000..a1de7d7 --- /dev/null +++ b/src/modules/branches/entities/branch-schedule.entity.ts @@ -0,0 +1,73 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Branch } from './branch.entity'; + +export type ScheduleType = 'regular' | 'holiday' | 'special'; + +@Entity({ name: 'branch_schedules', schema: 'core' }) +export class BranchSchedule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + // Identificacion + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Tipo + @Column({ name: 'schedule_type', type: 'varchar', length: 30, default: 'regular' }) + scheduleType: ScheduleType; + + // Dia de la semana (0=domingo, 1=lunes, ..., 6=sabado) o fecha especifica + @Index() + @Column({ name: 'day_of_week', type: 'integer', nullable: true }) + dayOfWeek: number; + + @Index() + @Column({ name: 'specific_date', type: 'date', nullable: true }) + specificDate: Date; + + // Horarios + @Column({ name: 'open_time', type: 'time' }) + openTime: string; + + @Column({ name: 'close_time', type: 'time' }) + closeTime: string; + + // Turnos (si aplica) + @Column({ type: 'jsonb', default: [] }) + shifts: Array<{ + name: string; + start: string; + end: string; + }>; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relaciones + @ManyToOne(() => Branch, (branch) => branch.schedules, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'branch_id' }) + branch: Branch; +} diff --git a/src/modules/branches/entities/branch.entity.ts b/src/modules/branches/entities/branch.entity.ts new file mode 100644 index 0000000..dcc596c --- /dev/null +++ b/src/modules/branches/entities/branch.entity.ts @@ -0,0 +1,158 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, + Unique, +} from 'typeorm'; +import { UserBranchAssignment } from './user-branch-assignment.entity'; +import { BranchSchedule } from './branch-schedule.entity'; +import { BranchPaymentTerminal } from './branch-payment-terminal.entity'; + +export type BranchType = 'headquarters' | 'regional' | 'store' | 'warehouse' | 'office' | 'factory'; + +@Entity({ name: 'branches', schema: 'core' }) +@Unique(['tenantId', 'code']) +export class Branch { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'short_name', type: 'varchar', length: 50, nullable: true }) + shortName: string; + + // Tipo + @Index() + @Column({ name: 'branch_type', type: 'varchar', length: 30, default: 'store' }) + branchType: BranchType; + + // Contacto + @Column({ type: 'varchar', length: 20, nullable: true }) + phone: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string; + + @Column({ name: 'manager_id', type: 'uuid', nullable: true }) + managerId: string; + + // Direccion + @Column({ name: 'address_line1', type: 'varchar', length: 200, nullable: true }) + addressLine1: string; + + @Column({ name: 'address_line2', type: 'varchar', length: 200, nullable: true }) + addressLine2: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + city: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + state: string; + + @Column({ name: 'postal_code', type: 'varchar', length: 20, nullable: true }) + postalCode: string; + + @Column({ type: 'varchar', length: 3, default: 'MEX' }) + country: string; + + // Geolocalizacion + @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + @Column({ name: 'geofence_radius', type: 'integer', default: 100 }) + geofenceRadius: number; // Radio en metros + + @Column({ name: 'geofence_enabled', type: 'boolean', default: true }) + geofenceEnabled: boolean; + + // Configuracion + @Column({ type: 'varchar', length: 50, default: 'America/Mexico_City' }) + timezone: string; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_main', type: 'boolean', default: false }) + isMain: boolean; // Sucursal principal/matriz + + // Horarios de operacion + @Column({ name: 'operating_hours', type: 'jsonb', default: {} }) + operatingHours: Record; + + // Configuraciones especificas + @Column({ type: 'jsonb', default: {} }) + settings: { + allowPos?: boolean; + allowWarehouse?: boolean; + allowCheckIn?: boolean; + [key: string]: any; + }; + + // Jerarquia (path materializado) + @Index() + @Column({ name: 'hierarchy_path', type: 'text', nullable: true }) + hierarchyPath: string; + + @Column({ name: 'hierarchy_level', type: 'integer', default: 0 }) + hierarchyLevel: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + // Relaciones + @ManyToOne(() => Branch, { nullable: true }) + @JoinColumn({ name: 'parent_id' }) + parent: Branch; + + @OneToMany(() => Branch, (branch) => branch.parent) + children: Branch[]; + + @OneToMany(() => UserBranchAssignment, (assignment) => assignment.branch) + userAssignments: UserBranchAssignment[]; + + @OneToMany(() => BranchSchedule, (schedule) => schedule.branch) + schedules: BranchSchedule[]; + + @OneToMany(() => BranchPaymentTerminal, (terminal) => terminal.branch) + paymentTerminals: BranchPaymentTerminal[]; +} diff --git a/src/modules/branches/entities/index.ts b/src/modules/branches/entities/index.ts new file mode 100644 index 0000000..ce1a718 --- /dev/null +++ b/src/modules/branches/entities/index.ts @@ -0,0 +1,5 @@ +export { Branch, BranchType } from './branch.entity'; +export { UserBranchAssignment, AssignmentType, BranchRole } from './user-branch-assignment.entity'; +export { BranchSchedule, ScheduleType } from './branch-schedule.entity'; +export { BranchPaymentTerminal, TerminalProvider, HealthStatus } from './branch-payment-terminal.entity'; +export { BranchInventorySettings } from './branch-inventory-settings.entity'; diff --git a/src/modules/branches/entities/user-branch-assignment.entity.ts b/src/modules/branches/entities/user-branch-assignment.entity.ts new file mode 100644 index 0000000..d2ccd55 --- /dev/null +++ b/src/modules/branches/entities/user-branch-assignment.entity.ts @@ -0,0 +1,72 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { Branch } from './branch.entity'; + +export type AssignmentType = 'primary' | 'secondary' | 'temporary' | 'floating'; +export type BranchRole = 'manager' | 'supervisor' | 'staff'; + +@Entity({ name: 'user_branch_assignments', schema: 'core' }) +@Unique(['userId', 'branchId', 'assignmentType']) +export class UserBranchAssignment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid' }) + branchId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Tipo de asignacion + @Column({ name: 'assignment_type', type: 'varchar', length: 30, default: 'primary' }) + assignmentType: AssignmentType; + + // Rol en la sucursal + @Column({ name: 'branch_role', type: 'varchar', length: 50, nullable: true }) + branchRole: BranchRole; + + // Permisos especificos + @Column({ type: 'jsonb', default: [] }) + permissions: string[]; + + // Vigencia (para asignaciones temporales) + @Column({ name: 'valid_from', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + validFrom: Date; + + @Column({ name: 'valid_until', type: 'timestamptz', nullable: true }) + validUntil: Date; + + // Estado + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + // Relaciones + @ManyToOne(() => Branch, (branch) => branch.userAssignments, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'branch_id' }) + branch: Branch; +} diff --git a/src/modules/branches/index.ts b/src/modules/branches/index.ts new file mode 100644 index 0000000..c68988b --- /dev/null +++ b/src/modules/branches/index.ts @@ -0,0 +1,5 @@ +export { BranchesModule, BranchesModuleOptions } from './branches.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/branches/services/branches.service.ts b/src/modules/branches/services/branches.service.ts new file mode 100644 index 0000000..51fefa8 --- /dev/null +++ b/src/modules/branches/services/branches.service.ts @@ -0,0 +1,435 @@ +import { Repository, FindOptionsWhere, ILike, IsNull, In } from 'typeorm'; +import { Branch, UserBranchAssignment, BranchSchedule, BranchPaymentTerminal } from '../entities'; +import { CreateBranchDto, UpdateBranchDto, AssignUserToBranchDto, CreateBranchScheduleDto } from '../dto'; + +export interface BranchSearchParams { + search?: string; + branchType?: string; + isActive?: boolean; + parentId?: string; + includeChildren?: boolean; + limit?: number; + offset?: number; +} + +export class BranchesService { + constructor( + private readonly branchRepository: Repository, + private readonly assignmentRepository: Repository, + private readonly scheduleRepository: Repository, + private readonly terminalRepository: Repository + ) {} + + // ============================================ + // BRANCH CRUD + // ============================================ + + async findAll(tenantId: string, params: BranchSearchParams = {}): Promise<{ data: Branch[]; total: number }> { + const { search, branchType, isActive, parentId, limit = 50, offset = 0 } = params; + + const where: FindOptionsWhere = { tenantId }; + + if (branchType) where.branchType = branchType as any; + if (isActive !== undefined) where.isActive = isActive; + if (parentId) where.parentId = parentId; + if (parentId === null) where.parentId = IsNull(); + + const queryBuilder = this.branchRepository + .createQueryBuilder('branch') + .where('branch.tenant_id = :tenantId', { tenantId }) + .leftJoinAndSelect('branch.schedules', 'schedules') + .leftJoinAndSelect('branch.paymentTerminals', 'terminals'); + + if (search) { + queryBuilder.andWhere('(branch.name ILIKE :search OR branch.code ILIKE :search OR branch.city ILIKE :search)', { + search: `%${search}%`, + }); + } + + if (branchType) { + queryBuilder.andWhere('branch.branch_type = :branchType', { branchType }); + } + + if (isActive !== undefined) { + queryBuilder.andWhere('branch.is_active = :isActive', { isActive }); + } + + if (parentId) { + queryBuilder.andWhere('branch.parent_id = :parentId', { parentId }); + } else if (parentId === null) { + queryBuilder.andWhere('branch.parent_id IS NULL'); + } + + queryBuilder.orderBy('branch.hierarchy_path', 'ASC').addOrderBy('branch.name', 'ASC'); + + const total = await queryBuilder.getCount(); + const data = await queryBuilder.skip(offset).take(limit).getMany(); + + return { data, total }; + } + + async findOne(id: string): Promise { + return this.branchRepository.findOne({ + where: { id }, + relations: ['parent', 'children', 'schedules', 'paymentTerminals', 'userAssignments'], + }); + } + + async findByCode(tenantId: string, code: string): Promise { + return this.branchRepository.findOne({ + where: { tenantId, code }, + relations: ['schedules', 'paymentTerminals'], + }); + } + + async create(tenantId: string, dto: CreateBranchDto, createdBy?: string): Promise { + // Check for duplicate code + const existing = await this.findByCode(tenantId, dto.code); + if (existing) { + throw new Error(`Branch with code '${dto.code}' already exists`); + } + + // Build hierarchy path + let hierarchyPath = `/${dto.code}`; + let hierarchyLevel = 0; + + if (dto.parentId) { + const parent = await this.findOne(dto.parentId); + if (!parent) { + throw new Error('Parent branch not found'); + } + hierarchyPath = `${parent.hierarchyPath}/${dto.code}`; + hierarchyLevel = parent.hierarchyLevel + 1; + } + + const branch = this.branchRepository.create({ + ...dto, + tenantId, + hierarchyPath, + hierarchyLevel, + createdBy, + }); + + return this.branchRepository.save(branch); + } + + async update(id: string, dto: UpdateBranchDto, updatedBy?: string): Promise { + const branch = await this.findOne(id); + if (!branch) return null; + + // If changing parent, update hierarchy + if (dto.parentId !== undefined && dto.parentId !== branch.parentId) { + if (dto.parentId) { + const newParent = await this.findOne(dto.parentId); + if (!newParent) { + throw new Error('New parent branch not found'); + } + + // Check for circular reference + if (newParent.hierarchyPath.includes(`/${branch.code}/`) || newParent.id === branch.id) { + throw new Error('Cannot create circular reference in branch hierarchy'); + } + + branch.hierarchyPath = `${newParent.hierarchyPath}/${branch.code}`; + branch.hierarchyLevel = newParent.hierarchyLevel + 1; + } else { + branch.hierarchyPath = `/${branch.code}`; + branch.hierarchyLevel = 0; + } + + // Update children hierarchy paths + await this.updateChildrenHierarchy(branch); + } + + Object.assign(branch, dto, { updatedBy }); + return this.branchRepository.save(branch); + } + + private async updateChildrenHierarchy(parent: Branch): Promise { + const children = await this.branchRepository.find({ + where: { parentId: parent.id }, + }); + + for (const child of children) { + child.hierarchyPath = `${parent.hierarchyPath}/${child.code}`; + child.hierarchyLevel = parent.hierarchyLevel + 1; + await this.branchRepository.save(child); + await this.updateChildrenHierarchy(child); + } + } + + async delete(id: string): Promise { + const branch = await this.findOne(id); + if (!branch) return false; + + // Check if has children + const childrenCount = await this.branchRepository.count({ where: { parentId: id } }); + if (childrenCount > 0) { + throw new Error('Cannot delete branch with children. Delete children first or move them to another parent.'); + } + + await this.branchRepository.softDelete(id); + return true; + } + + // ============================================ + // HIERARCHY + // ============================================ + + async getHierarchy(tenantId: string): Promise { + const branches = await this.branchRepository.find({ + where: { tenantId, isActive: true }, + order: { hierarchyPath: 'ASC' }, + }); + + return this.buildTree(branches); + } + + private buildTree(branches: Branch[], parentId: string | null = null): Branch[] { + return branches + .filter((b) => b.parentId === parentId) + .map((branch) => ({ + ...branch, + children: this.buildTree(branches, branch.id), + })); + } + + async getChildren(branchId: string, recursive: boolean = false): Promise { + if (!recursive) { + return this.branchRepository.find({ + where: { parentId: branchId, isActive: true }, + order: { name: 'ASC' }, + }); + } + + const parent = await this.findOne(branchId); + if (!parent) return []; + + return this.branchRepository + .createQueryBuilder('branch') + .where('branch.hierarchy_path LIKE :path', { path: `${parent.hierarchyPath}/%` }) + .andWhere('branch.is_active = true') + .orderBy('branch.hierarchy_path', 'ASC') + .getMany(); + } + + async getParents(branchId: string): Promise { + const branch = await this.findOne(branchId); + if (!branch || !branch.hierarchyPath) return []; + + const codes = branch.hierarchyPath.split('/').filter((c) => c && c !== branch.code); + if (codes.length === 0) return []; + + return this.branchRepository.find({ + where: { tenantId: branch.tenantId, code: In(codes) }, + order: { hierarchyLevel: 'ASC' }, + }); + } + + // ============================================ + // USER ASSIGNMENTS + // ============================================ + + async assignUser(tenantId: string, dto: AssignUserToBranchDto, assignedBy?: string): Promise { + // Check if branch exists + const branch = await this.findOne(dto.branchId); + if (!branch || branch.tenantId !== tenantId) { + throw new Error('Branch not found'); + } + + // Check for existing assignment of same type + const existing = await this.assignmentRepository.findOne({ + where: { + userId: dto.userId, + branchId: dto.branchId, + assignmentType: (dto.assignmentType as any) ?? 'primary', + }, + }); + + if (existing) { + // Update existing + Object.assign(existing, { + branchRole: dto.branchRole ?? existing.branchRole, + permissions: dto.permissions ?? existing.permissions, + validUntil: dto.validUntil ? new Date(dto.validUntil) : existing.validUntil, + isActive: true, + }); + return this.assignmentRepository.save(existing); + } + + const assignment = this.assignmentRepository.create({ + ...dto, + tenantId, + validUntil: dto.validUntil ? new Date(dto.validUntil) : undefined, + createdBy: assignedBy, + } as any); + + return this.assignmentRepository.save(assignment); + } + + async unassignUser(userId: string, branchId: string): Promise { + const result = await this.assignmentRepository.update({ userId, branchId }, { isActive: false }); + return (result.affected ?? 0) > 0; + } + + async getUserBranches(userId: string): Promise { + const assignments = await this.assignmentRepository.find({ + where: { userId, isActive: true }, + relations: ['branch'], + }); + + return assignments.map((a) => a.branch).filter((b) => b != null); + } + + async getBranchUsers(branchId: string): Promise { + return this.assignmentRepository.find({ + where: { branchId, isActive: true }, + order: { branchRole: 'ASC' }, + }); + } + + async getPrimaryBranch(userId: string): Promise { + const assignment = await this.assignmentRepository.findOne({ + where: { userId, assignmentType: 'primary' as any, isActive: true }, + relations: ['branch'], + }); + + return assignment?.branch ?? null; + } + + // ============================================ + // GEOFENCING + // ============================================ + + async validateGeofence(branchId: string, latitude: number, longitude: number): Promise<{ valid: boolean; distance: number }> { + const branch = await this.findOne(branchId); + if (!branch) { + throw new Error('Branch not found'); + } + + if (!branch.geofenceEnabled) { + return { valid: true, distance: 0 }; + } + + if (!branch.latitude || !branch.longitude) { + return { valid: true, distance: 0 }; + } + + // Calculate distance using Haversine formula + const R = 6371000; // Earth's radius in meters + const dLat = this.toRad(latitude - branch.latitude); + const dLon = this.toRad(longitude - branch.longitude); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRad(branch.latitude)) * Math.cos(this.toRad(latitude)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const distance = R * c; + + return { + valid: distance <= branch.geofenceRadius, + distance: Math.round(distance), + }; + } + + private toRad(deg: number): number { + return deg * (Math.PI / 180); + } + + async findNearbyBranches(tenantId: string, latitude: number, longitude: number, radiusMeters: number = 5000): Promise { + // Use PostgreSQL's earthdistance extension if available, otherwise calculate in app + const branches = await this.branchRepository.find({ + where: { tenantId, isActive: true }, + }); + + return branches + .filter((b) => { + if (!b.latitude || !b.longitude) return false; + const result = this.calculateDistance(latitude, longitude, b.latitude, b.longitude); + return result <= radiusMeters; + }) + .sort((a, b) => { + const distA = this.calculateDistance(latitude, longitude, a.latitude!, a.longitude!); + const distB = this.calculateDistance(latitude, longitude, b.latitude!, b.longitude!); + return distA - distB; + }); + } + + private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { + const R = 6371000; + const dLat = this.toRad(lat2 - lat1); + const dLon = this.toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + // ============================================ + // SCHEDULES + // ============================================ + + async addSchedule(branchId: string, dto: CreateBranchScheduleDto): Promise { + const schedule = this.scheduleRepository.create({ + ...dto, + branchId, + specificDate: dto.specificDate ? new Date(dto.specificDate) : undefined, + }); + + return this.scheduleRepository.save(schedule); + } + + async getSchedules(branchId: string): Promise { + return this.scheduleRepository.find({ + where: { branchId, isActive: true }, + order: { dayOfWeek: 'ASC', specificDate: 'ASC' }, + }); + } + + async isOpenNow(branchId: string): Promise { + const schedules = await this.getSchedules(branchId); + const now = new Date(); + const dayOfWeek = now.getDay(); + const currentTime = now.toTimeString().slice(0, 5); + + // Check for specific date schedule first + const today = now.toISOString().slice(0, 10); + const specificSchedule = schedules.find((s) => s.specificDate?.toISOString().slice(0, 10) === today); + + if (specificSchedule) { + return currentTime >= specificSchedule.openTime && currentTime <= specificSchedule.closeTime; + } + + // Check regular schedule + const regularSchedule = schedules.find((s) => s.dayOfWeek === dayOfWeek && s.scheduleType === 'regular'); + + if (regularSchedule) { + return currentTime >= regularSchedule.openTime && currentTime <= regularSchedule.closeTime; + } + + return false; + } + + // ============================================ + // MAIN BRANCH + // ============================================ + + async getMainBranch(tenantId: string): Promise { + return this.branchRepository.findOne({ + where: { tenantId, isMain: true, isActive: true }, + relations: ['schedules', 'paymentTerminals'], + }); + } + + async setAsMainBranch(branchId: string): Promise { + const branch = await this.findOne(branchId); + if (!branch) return null; + + // Unset current main branch + await this.branchRepository.update({ tenantId: branch.tenantId, isMain: true }, { isMain: false }); + + // Set new main branch + branch.isMain = true; + return this.branchRepository.save(branch); + } +} diff --git a/src/modules/branches/services/index.ts b/src/modules/branches/services/index.ts new file mode 100644 index 0000000..0db219e --- /dev/null +++ b/src/modules/branches/services/index.ts @@ -0,0 +1 @@ +export { BranchesService, BranchSearchParams } from './branches.service'; 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/dashboard/controllers/index.ts b/src/modules/dashboard/controllers/index.ts new file mode 100644 index 0000000..aeab30d --- /dev/null +++ b/src/modules/dashboard/controllers/index.ts @@ -0,0 +1,96 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { DashboardService } from '../services'; + +export class DashboardController { + public router: Router; + + constructor(private readonly dashboardService: DashboardService) { + this.router = Router(); + + this.router.get('/', this.getDashboard.bind(this)); + this.router.get('/kpis', this.getKPIs.bind(this)); + this.router.get('/activity', this.getActivity.bind(this)); + this.router.get('/sales-chart', this.getSalesChart.bind(this)); + } + + private async getDashboard(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const [kpis, activity, salesChart] = await Promise.all([ + this.dashboardService.getKPIs(tenantId), + this.dashboardService.getActivity(tenantId), + this.dashboardService.getSalesChart(tenantId, 'month'), + ]); + + res.json({ + kpis, + activity, + salesChart, + timestamp: new Date().toISOString(), + }); + } catch (e) { + next(e); + } + } + + private async getKPIs(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const kpis = await this.dashboardService.getKPIs(tenantId); + res.json({ data: kpis }); + } catch (e) { + next(e); + } + } + + private async getActivity(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const { limit } = req.query; + const activity = await this.dashboardService.getActivity( + tenantId, + limit ? parseInt(limit as string) : 5 + ); + + res.json({ data: activity }); + } catch (e) { + next(e); + } + } + + private async getSalesChart(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const { period } = req.query; + const validPeriods = ['week', 'month', 'year']; + const selectedPeriod = validPeriods.includes(period as string) + ? (period as 'week' | 'month' | 'year') + : 'month'; + + const chartData = await this.dashboardService.getSalesChart(tenantId, selectedPeriod); + res.json({ data: chartData }); + } catch (e) { + next(e); + } + } +} diff --git a/src/modules/dashboard/dashboard.module.ts b/src/modules/dashboard/dashboard.module.ts new file mode 100644 index 0000000..bf8908d --- /dev/null +++ b/src/modules/dashboard/dashboard.module.ts @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { DashboardService } from './services'; +import { DashboardController } from './controllers'; + +export interface DashboardModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class DashboardModule { + public router: Router; + public dashboardService: DashboardService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: DashboardModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + this.dashboardService = new DashboardService(this.dataSource); + } + + private initializeRoutes(): void { + const dashboardController = new DashboardController(this.dashboardService); + this.router.use(`${this.basePath}/dashboard`, dashboardController.router); + } + + // Dashboard module doesn't have its own entities - it uses data from other modules + static getEntities(): Function[] { + return []; + } +} diff --git a/src/modules/dashboard/index.ts b/src/modules/dashboard/index.ts new file mode 100644 index 0000000..04a093a --- /dev/null +++ b/src/modules/dashboard/index.ts @@ -0,0 +1,3 @@ +export { DashboardModule, DashboardModuleOptions } from './dashboard.module'; +export { DashboardService } from './services'; +export { DashboardController } from './controllers'; diff --git a/src/modules/dashboard/services/index.ts b/src/modules/dashboard/services/index.ts new file mode 100644 index 0000000..d879b28 --- /dev/null +++ b/src/modules/dashboard/services/index.ts @@ -0,0 +1,386 @@ +import { DataSource } from 'typeorm'; + +export interface DashboardKPIs { + sales: { + todayRevenue: number; + monthRevenue: number; + todayOrders: number; + monthOrders: number; + pendingOrders: number; + }; + inventory: { + totalProducts: number; + lowStockItems: number; + outOfStockItems: number; + pendingMovements: number; + }; + invoices: { + pendingInvoices: number; + overdueInvoices: number; + totalReceivable: number; + totalPayable: number; + }; + partners: { + totalCustomers: number; + totalSuppliers: number; + newCustomersMonth: number; + }; +} + +export interface DashboardActivity { + recentOrders: any[]; + recentInvoices: any[]; + recentMovements: any[]; + alerts: any[]; +} + +export class DashboardService { + constructor(private readonly dataSource: DataSource) {} + + async getKPIs(tenantId: string): Promise { + const [sales, inventory, invoices, partners] = await Promise.all([ + this.getSalesKPIs(tenantId), + this.getInventoryKPIs(tenantId), + this.getInvoiceKPIs(tenantId), + this.getPartnerKPIs(tenantId), + ]); + + return { sales, inventory, invoices, partners }; + } + + private async getSalesKPIs(tenantId: string): Promise { + const today = new Date(); + const startOfDay = new Date(today.setHours(0, 0, 0, 0)); + const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + + try { + // Today's sales + const todayQuery = ` + SELECT + COALESCE(SUM(total), 0) as revenue, + COUNT(*) as orders + FROM sales.sales_orders + WHERE tenant_id = $1 + AND status NOT IN ('cancelled', 'draft') + AND order_date >= $2 + `; + const todayResult = await this.dataSource.query(todayQuery, [tenantId, startOfDay]); + + // Month's sales + const monthQuery = ` + SELECT + COALESCE(SUM(total), 0) as revenue, + COUNT(*) as orders + FROM sales.sales_orders + WHERE tenant_id = $1 + AND status NOT IN ('cancelled', 'draft') + AND order_date >= $2 + `; + const monthResult = await this.dataSource.query(monthQuery, [tenantId, startOfMonth]); + + // Pending orders + const pendingQuery = ` + SELECT COUNT(*) as count + FROM sales.sales_orders + WHERE tenant_id = $1 + AND status IN ('confirmed', 'processing') + `; + const pendingResult = await this.dataSource.query(pendingQuery, [tenantId]); + + return { + todayRevenue: parseFloat(todayResult[0]?.revenue) || 0, + monthRevenue: parseFloat(monthResult[0]?.revenue) || 0, + todayOrders: parseInt(todayResult[0]?.orders) || 0, + monthOrders: parseInt(monthResult[0]?.orders) || 0, + pendingOrders: parseInt(pendingResult[0]?.count) || 0, + }; + } catch { + return { + todayRevenue: 0, + monthRevenue: 0, + todayOrders: 0, + monthOrders: 0, + pendingOrders: 0, + }; + } + } + + private async getInventoryKPIs(tenantId: string): Promise { + try { + const stockQuery = ` + SELECT + COUNT(DISTINCT product_id) as total_products, + COUNT(CASE WHEN quantity_on_hand <= 0 THEN 1 END) as out_of_stock, + COUNT(CASE WHEN quantity_on_hand > 0 AND quantity_on_hand <= 10 THEN 1 END) as low_stock + FROM inventory.stock_levels + WHERE tenant_id = $1 + `; + const stockResult = await this.dataSource.query(stockQuery, [tenantId]); + + const movementsQuery = ` + SELECT COUNT(*) as count + FROM inventory.stock_movements + WHERE tenant_id = $1 + AND status = 'draft' + `; + const movementsResult = await this.dataSource.query(movementsQuery, [tenantId]); + + return { + totalProducts: parseInt(stockResult[0]?.total_products) || 0, + lowStockItems: parseInt(stockResult[0]?.low_stock) || 0, + outOfStockItems: parseInt(stockResult[0]?.out_of_stock) || 0, + pendingMovements: parseInt(movementsResult[0]?.count) || 0, + }; + } catch { + return { + totalProducts: 0, + lowStockItems: 0, + outOfStockItems: 0, + pendingMovements: 0, + }; + } + } + + private async getInvoiceKPIs(tenantId: string): Promise { + try { + const invoicesQuery = ` + SELECT + COUNT(CASE WHEN status IN ('validated', 'sent') THEN 1 END) as pending, + COUNT(CASE WHEN status IN ('validated', 'sent', 'partial') AND due_date < CURRENT_DATE THEN 1 END) as overdue, + COALESCE(SUM(CASE WHEN invoice_type = 'sale' AND status IN ('validated', 'sent', 'partial') THEN (total - amount_paid) END), 0) as receivable, + COALESCE(SUM(CASE WHEN invoice_type = 'purchase' AND status IN ('validated', 'sent', 'partial') THEN (total - amount_paid) END), 0) as payable + FROM billing.invoices + WHERE tenant_id = $1 + `; + const result = await this.dataSource.query(invoicesQuery, [tenantId]); + + return { + pendingInvoices: parseInt(result[0]?.pending) || 0, + overdueInvoices: parseInt(result[0]?.overdue) || 0, + totalReceivable: parseFloat(result[0]?.receivable) || 0, + totalPayable: parseFloat(result[0]?.payable) || 0, + }; + } catch { + return { + pendingInvoices: 0, + overdueInvoices: 0, + totalReceivable: 0, + totalPayable: 0, + }; + } + } + + private async getPartnerKPIs(tenantId: string): Promise { + const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1); + + try { + const partnersQuery = ` + SELECT + COUNT(CASE WHEN partner_type IN ('customer', 'both') THEN 1 END) as customers, + COUNT(CASE WHEN partner_type IN ('supplier', 'both') THEN 1 END) as suppliers, + COUNT(CASE WHEN partner_type IN ('customer', 'both') AND created_at >= $2 THEN 1 END) as new_customers + FROM partners.partners + WHERE tenant_id = $1 + AND deleted_at IS NULL + `; + const result = await this.dataSource.query(partnersQuery, [tenantId, startOfMonth]); + + return { + totalCustomers: parseInt(result[0]?.customers) || 0, + totalSuppliers: parseInt(result[0]?.suppliers) || 0, + newCustomersMonth: parseInt(result[0]?.new_customers) || 0, + }; + } catch { + return { + totalCustomers: 0, + totalSuppliers: 0, + newCustomersMonth: 0, + }; + } + } + + async getActivity(tenantId: string, limit: number = 5): Promise { + const [recentOrders, recentInvoices, recentMovements, alerts] = await Promise.all([ + this.getRecentOrders(tenantId, limit), + this.getRecentInvoices(tenantId, limit), + this.getRecentMovements(tenantId, limit), + this.getAlerts(tenantId), + ]); + + return { recentOrders, recentInvoices, recentMovements, alerts }; + } + + private async getRecentOrders(tenantId: string, limit: number): Promise { + try { + const query = ` + SELECT + id, + order_number, + partner_name, + total, + status, + order_date, + created_at + FROM sales.sales_orders + WHERE tenant_id = $1 + ORDER BY created_at DESC + LIMIT $2 + `; + return await this.dataSource.query(query, [tenantId, limit]); + } catch { + return []; + } + } + + private async getRecentInvoices(tenantId: string, limit: number): Promise { + try { + const query = ` + SELECT + id, + invoice_number, + invoice_type, + partner_name, + total, + status, + invoice_date, + created_at + FROM billing.invoices + WHERE tenant_id = $1 + ORDER BY created_at DESC + LIMIT $2 + `; + return await this.dataSource.query(query, [tenantId, limit]); + } catch { + return []; + } + } + + private async getRecentMovements(tenantId: string, limit: number): Promise { + try { + const query = ` + SELECT + id, + movement_number, + movement_type, + product_id, + quantity, + status, + created_at + FROM inventory.stock_movements + WHERE tenant_id = $1 + ORDER BY created_at DESC + LIMIT $2 + `; + return await this.dataSource.query(query, [tenantId, limit]); + } catch { + return []; + } + } + + private async getAlerts(tenantId: string): Promise { + const alerts: any[] = []; + + try { + // Low stock alerts + const lowStockQuery = ` + SELECT COUNT(*) as count + FROM inventory.stock_levels + WHERE tenant_id = $1 + AND quantity_on_hand > 0 + AND quantity_on_hand <= 10 + `; + const lowStockResult = await this.dataSource.query(lowStockQuery, [tenantId]); + const lowStockCount = parseInt(lowStockResult[0]?.count) || 0; + + if (lowStockCount > 0) { + alerts.push({ + type: 'warning', + category: 'inventory', + message: `${lowStockCount} productos con stock bajo`, + count: lowStockCount, + }); + } + + // Overdue invoices alerts + const overdueQuery = ` + SELECT COUNT(*) as count + FROM billing.invoices + WHERE tenant_id = $1 + AND invoice_type = 'sale' + AND status IN ('validated', 'sent', 'partial') + AND due_date < CURRENT_DATE + `; + const overdueResult = await this.dataSource.query(overdueQuery, [tenantId]); + const overdueCount = parseInt(overdueResult[0]?.count) || 0; + + if (overdueCount > 0) { + alerts.push({ + type: 'error', + category: 'invoices', + message: `${overdueCount} facturas vencidas`, + count: overdueCount, + }); + } + + // Pending orders alerts + const pendingOrdersQuery = ` + SELECT COUNT(*) as count + FROM sales.sales_orders + WHERE tenant_id = $1 + AND status IN ('confirmed') + AND order_date < CURRENT_DATE - INTERVAL '3 days' + `; + const pendingResult = await this.dataSource.query(pendingOrdersQuery, [tenantId]); + const pendingCount = parseInt(pendingResult[0]?.count) || 0; + + if (pendingCount > 0) { + alerts.push({ + type: 'warning', + category: 'sales', + message: `${pendingCount} pedidos pendientes hace mas de 3 dias`, + count: pendingCount, + }); + } + } catch { + // Ignore errors - tables might not exist yet + } + + return alerts; + } + + async getSalesChart( + tenantId: string, + period: 'week' | 'month' | 'year' = 'month' + ): Promise<{ labels: string[]; data: number[] }> { + try { + const intervals = { + week: { interval: '7 days', format: 'Dy', group: 'day' }, + month: { interval: '30 days', format: 'DD', group: 'day' }, + year: { interval: '12 months', format: 'Mon', group: 'month' }, + }; + + const config = intervals[period]; + + const query = ` + SELECT + TO_CHAR(order_date, '${config.format}') as label, + COALESCE(SUM(total), 0) as total + FROM sales.sales_orders + WHERE tenant_id = $1 + AND status NOT IN ('cancelled', 'draft') + AND order_date >= CURRENT_DATE - INTERVAL '${config.interval}' + GROUP BY DATE_TRUNC('${config.group}', order_date), TO_CHAR(order_date, '${config.format}') + ORDER BY DATE_TRUNC('${config.group}', order_date) + `; + + const result = await this.dataSource.query(query, [tenantId]); + + return { + labels: result.map((r: any) => r.label), + data: result.map((r: any) => parseFloat(r.total) || 0), + }; + } catch { + return { labels: [], data: [] }; + } + } +} diff --git a/src/modules/feature-flags/controllers/feature-flags.controller.ts b/src/modules/feature-flags/controllers/feature-flags.controller.ts new file mode 100644 index 0000000..13c0e0c --- /dev/null +++ b/src/modules/feature-flags/controllers/feature-flags.controller.ts @@ -0,0 +1,367 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { FeatureFlagsService } from '../services/feature-flags.service'; + +export class FeatureFlagsController { + public router: Router; + + constructor(private readonly featureFlagsService: FeatureFlagsService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Flag CRUD + this.router.get('/flags', this.findAllFlags.bind(this)); + this.router.get('/flags/all', this.findAllFlagsIncludingInactive.bind(this)); + this.router.get('/flags/tags/:tags', this.findFlagsByTags.bind(this)); + this.router.get('/flags/:id', this.findFlagById.bind(this)); + this.router.get('/flags/code/:code', this.findFlagByCode.bind(this)); + this.router.post('/flags', this.createFlag.bind(this)); + this.router.patch('/flags/:id', this.updateFlag.bind(this)); + this.router.delete('/flags/:id', this.deleteFlag.bind(this)); + this.router.patch('/flags/:id/toggle', this.toggleFlag.bind(this)); + this.router.get('/flags/:id/stats', this.getFlagStats.bind(this)); + + // Tenant Overrides + this.router.get('/flags/:flagId/overrides', this.findOverridesForFlag.bind(this)); + this.router.get('/tenants/:tenantId/overrides', this.findOverridesForTenant.bind(this)); + this.router.get('/overrides/:id', this.findOverrideById.bind(this)); + this.router.post('/overrides', this.createOverride.bind(this)); + this.router.patch('/overrides/:id', this.updateOverride.bind(this)); + this.router.delete('/overrides/:id', this.deleteOverride.bind(this)); + + // Evaluation + this.router.get('/evaluate/:code', this.evaluateFlag.bind(this)); + this.router.post('/evaluate', this.evaluateFlags.bind(this)); + this.router.get('/is-enabled/:code', this.isEnabled.bind(this)); + + // Maintenance + this.router.post('/maintenance/cleanup', this.cleanupExpiredOverrides.bind(this)); + } + + // ============================================ + // FLAGS + // ============================================ + + private async findAllFlags(req: Request, res: Response, next: NextFunction): Promise { + try { + const flags = await this.featureFlagsService.findAllFlags(); + res.json({ data: flags, total: flags.length }); + } catch (error) { + next(error); + } + } + + private async findAllFlagsIncludingInactive( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const flags = await this.featureFlagsService.findAllFlagsIncludingInactive(); + res.json({ data: flags, total: flags.length }); + } catch (error) { + next(error); + } + } + + private async findFlagById(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const flag = await this.featureFlagsService.findFlagById(id); + + if (!flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async findFlagByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const flag = await this.featureFlagsService.findFlagByCode(code); + + if (!flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async findFlagsByTags(req: Request, res: Response, next: NextFunction): Promise { + try { + const { tags } = req.params; + const tagList = tags.split(','); + const flags = await this.featureFlagsService.findFlagsByTags(tagList); + res.json({ data: flags, total: flags.length }); + } catch (error) { + next(error); + } + } + + private async createFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = req.headers['x-user-id'] as string; + const flag = await this.featureFlagsService.createFlag(req.body, userId); + res.status(201).json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async updateFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const userId = req.headers['x-user-id'] as string; + const flag = await this.featureFlagsService.updateFlag(id, req.body, userId); + + if (!flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async deleteFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { soft } = req.query; + const userId = req.headers['x-user-id'] as string; + + let result: boolean; + if (soft === 'true') { + const flag = await this.featureFlagsService.softDeleteFlag(id, userId); + result = flag !== null; + } else { + result = await this.featureFlagsService.deleteFlag(id); + } + + if (!result) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async toggleFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { enabled } = req.body; + const userId = req.headers['x-user-id'] as string; + + const flag = await this.featureFlagsService.toggleFlag(id, enabled, userId); + + if (!flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: flag }); + } catch (error) { + next(error); + } + } + + private async getFlagStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const stats = await this.featureFlagsService.getFlagStats(id); + + if (!stats.flag) { + res.status(404).json({ error: 'Flag not found' }); + return; + } + + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + // ============================================ + // OVERRIDES + // ============================================ + + private async findOverridesForFlag( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const { flagId } = req.params; + const overrides = await this.featureFlagsService.findOverridesForFlag(flagId); + res.json({ data: overrides, total: overrides.length }); + } catch (error) { + next(error); + } + } + + private async findOverridesForTenant( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const { tenantId } = req.params; + const overrides = await this.featureFlagsService.findOverridesForTenant(tenantId); + res.json({ data: overrides, total: overrides.length }); + } catch (error) { + next(error); + } + } + + private async findOverrideById( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const { id } = req.params; + const override = await this.featureFlagsService.findOverrideById(id); + + if (!override) { + res.status(404).json({ error: 'Override not found' }); + return; + } + + res.json({ data: override }); + } catch (error) { + next(error); + } + } + + private async createOverride(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = req.headers['x-user-id'] as string; + const override = await this.featureFlagsService.createOverride(req.body, userId); + res.status(201).json({ data: override }); + } catch (error) { + next(error); + } + } + + private async updateOverride(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const override = await this.featureFlagsService.updateOverride(id, req.body); + + if (!override) { + res.status(404).json({ error: 'Override not found' }); + return; + } + + res.json({ data: override }); + } catch (error) { + next(error); + } + } + + private async deleteOverride(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deleted = await this.featureFlagsService.deleteOverride(id); + + if (!deleted) { + res.status(404).json({ error: 'Override not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // EVALUATION + // ============================================ + + private async evaluateFlag(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const tenantId = req.headers['x-tenant-id'] as string || req.query.tenantId as string; + + if (!tenantId) { + res.status(400).json({ error: 'tenantId is required (header x-tenant-id or query param)' }); + return; + } + + const result = await this.featureFlagsService.evaluateFlag(code, tenantId); + res.json({ data: result }); + } catch (error) { + next(error); + } + } + + private async evaluateFlags(req: Request, res: Response, next: NextFunction): Promise { + try { + const { flagCodes, tenantId } = req.body; + + if (!flagCodes || !Array.isArray(flagCodes)) { + res.status(400).json({ error: 'flagCodes array is required' }); + return; + } + + if (!tenantId) { + res.status(400).json({ error: 'tenantId is required' }); + return; + } + + const results = await this.featureFlagsService.evaluateFlags(flagCodes, tenantId); + res.json({ data: results, total: results.length }); + } catch (error) { + next(error); + } + } + + private async isEnabled(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const tenantId = req.headers['x-tenant-id'] as string || req.query.tenantId as string; + + if (!tenantId) { + res.status(400).json({ error: 'tenantId is required (header x-tenant-id or query param)' }); + return; + } + + const enabled = await this.featureFlagsService.isEnabled(code, tenantId); + res.json({ data: { code, enabled } }); + } catch (error) { + next(error); + } + } + + // ============================================ + // MAINTENANCE + // ============================================ + + private async cleanupExpiredOverrides( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const count = await this.featureFlagsService.cleanupExpiredOverrides(); + res.json({ data: { cleanedUp: count } }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/feature-flags/controllers/index.ts b/src/modules/feature-flags/controllers/index.ts new file mode 100644 index 0000000..56046b6 --- /dev/null +++ b/src/modules/feature-flags/controllers/index.ts @@ -0,0 +1 @@ +export { FeatureFlagsController } from './feature-flags.controller'; diff --git a/src/modules/feature-flags/dto/feature-flag.dto.ts b/src/modules/feature-flags/dto/feature-flag.dto.ts new file mode 100644 index 0000000..cc395f5 --- /dev/null +++ b/src/modules/feature-flags/dto/feature-flag.dto.ts @@ -0,0 +1,53 @@ +// ===================================================== +// DTOs: Feature Flags +// Modulo: MGN-019 +// Version: 1.0.0 +// ===================================================== + +export interface CreateFlagDto { + code: string; + name: string; + description?: string; + enabled?: boolean; + rolloutPercentage?: number; + tags?: string[]; +} + +export interface UpdateFlagDto { + name?: string; + description?: string; + enabled?: boolean; + rolloutPercentage?: number; + tags?: string[]; + isActive?: boolean; +} + +export interface CreateTenantOverrideDto { + flagId: string; + tenantId: string; + enabled: boolean; + reason?: string; + expiresAt?: Date; +} + +export interface UpdateTenantOverrideDto { + enabled?: boolean; + reason?: string; + expiresAt?: Date | null; +} + +export interface EvaluateFlagDto { + flagCode: string; + tenantId: string; +} + +export interface EvaluateFlagsDto { + flagCodes: string[]; + tenantId: string; +} + +export interface FlagEvaluationResult { + code: string; + enabled: boolean; + source: 'override' | 'global' | 'rollout' | 'default'; +} diff --git a/src/modules/feature-flags/dto/index.ts b/src/modules/feature-flags/dto/index.ts new file mode 100644 index 0000000..8cba2ff --- /dev/null +++ b/src/modules/feature-flags/dto/index.ts @@ -0,0 +1 @@ +export * from './feature-flag.dto'; diff --git a/src/modules/feature-flags/entities/flag.entity.ts b/src/modules/feature-flags/entities/flag.entity.ts new file mode 100644 index 0000000..779b16f --- /dev/null +++ b/src/modules/feature-flags/entities/flag.entity.ts @@ -0,0 +1,57 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, + OneToMany, +} from 'typeorm'; +import { TenantOverride } from './tenant-override.entity'; + +@Entity({ name: 'flags', schema: 'feature_flags' }) +@Unique(['code']) +export class Flag { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'code', type: 'varchar', length: 50 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'enabled', type: 'boolean', default: false }) + enabled: boolean; + + @Column({ name: 'rollout_percentage', type: 'int', default: 100 }) + rolloutPercentage: number; + + @Column({ name: 'tags', type: 'text', array: true, nullable: true }) + tags: string[]; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @OneToMany(() => TenantOverride, (override) => override.flag) + overrides: TenantOverride[]; +} diff --git a/src/modules/feature-flags/entities/index.ts b/src/modules/feature-flags/entities/index.ts new file mode 100644 index 0000000..99afff4 --- /dev/null +++ b/src/modules/feature-flags/entities/index.ts @@ -0,0 +1,2 @@ +export { Flag } from './flag.entity'; +export { TenantOverride } from './tenant-override.entity'; diff --git a/src/modules/feature-flags/entities/tenant-override.entity.ts b/src/modules/feature-flags/entities/tenant-override.entity.ts new file mode 100644 index 0000000..eb65066 --- /dev/null +++ b/src/modules/feature-flags/entities/tenant-override.entity.ts @@ -0,0 +1,50 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Flag } from './flag.entity'; + +@Entity({ name: 'tenant_overrides', schema: 'feature_flags' }) +@Unique(['flagId', 'tenantId']) +export class TenantOverride { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'flag_id', type: 'uuid' }) + flagId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'enabled', type: 'boolean' }) + enabled: boolean; + + @Column({ name: 'reason', type: 'text', nullable: true }) + reason: string; + + @Index() + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => Flag, (flag) => flag.overrides, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'flag_id' }) + flag: Flag; +} diff --git a/src/modules/feature-flags/feature-flags.module.ts b/src/modules/feature-flags/feature-flags.module.ts new file mode 100644 index 0000000..82e814b --- /dev/null +++ b/src/modules/feature-flags/feature-flags.module.ts @@ -0,0 +1,44 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { FeatureFlagsService } from './services'; +import { FeatureFlagsController } from './controllers'; +import { Flag, TenantOverride } from './entities'; + +export interface FeatureFlagsModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class FeatureFlagsModule { + public router: Router; + public featureFlagsService: FeatureFlagsService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: FeatureFlagsModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const flagRepository = this.dataSource.getRepository(Flag); + const overrideRepository = this.dataSource.getRepository(TenantOverride); + + this.featureFlagsService = new FeatureFlagsService( + flagRepository, + overrideRepository + ); + } + + private initializeRoutes(): void { + const featureFlagsController = new FeatureFlagsController(this.featureFlagsService); + this.router.use(`${this.basePath}/feature-flags`, featureFlagsController.router); + } + + static getEntities(): Function[] { + return [Flag, TenantOverride]; + } +} diff --git a/src/modules/feature-flags/index.ts b/src/modules/feature-flags/index.ts new file mode 100644 index 0000000..2423724 --- /dev/null +++ b/src/modules/feature-flags/index.ts @@ -0,0 +1,5 @@ +export { FeatureFlagsModule, FeatureFlagsModuleOptions } from './feature-flags.module'; +export { FeatureFlagsService } from './services'; +export { FeatureFlagsController } from './controllers'; +export { Flag, TenantOverride } from './entities'; +export * from './dto'; diff --git a/src/modules/feature-flags/services/feature-flags.service.ts b/src/modules/feature-flags/services/feature-flags.service.ts new file mode 100644 index 0000000..5c8a86d --- /dev/null +++ b/src/modules/feature-flags/services/feature-flags.service.ts @@ -0,0 +1,345 @@ +import { Repository, In } from 'typeorm'; +import { createHash } from 'crypto'; +import { Flag, TenantOverride } from '../entities'; +import { + CreateFlagDto, + UpdateFlagDto, + CreateTenantOverrideDto, + UpdateTenantOverrideDto, + FlagEvaluationResult, +} from '../dto'; + +export class FeatureFlagsService { + constructor( + private readonly flagRepository: Repository, + private readonly overrideRepository: Repository + ) {} + + // ============================================ + // FLAGS - CRUD + // ============================================ + + async findAllFlags(): Promise { + return this.flagRepository.find({ + where: { isActive: true }, + order: { code: 'ASC' }, + }); + } + + async findAllFlagsIncludingInactive(): Promise { + return this.flagRepository.find({ + order: { code: 'ASC' }, + }); + } + + async findFlagById(id: string): Promise { + return this.flagRepository.findOne({ + where: { id }, + relations: ['overrides'], + }); + } + + async findFlagByCode(code: string): Promise { + return this.flagRepository.findOne({ + where: { code }, + relations: ['overrides'], + }); + } + + async findFlagsByTags(tags: string[]): Promise { + return this.flagRepository + .createQueryBuilder('flag') + .where('flag.is_active = true') + .andWhere('flag.tags && :tags', { tags }) + .orderBy('flag.code', 'ASC') + .getMany(); + } + + async createFlag(data: CreateFlagDto, createdBy?: string): Promise { + const flag = this.flagRepository.create({ + ...data, + createdBy, + }); + return this.flagRepository.save(flag); + } + + async updateFlag( + id: string, + data: UpdateFlagDto, + updatedBy?: string + ): Promise { + const flag = await this.flagRepository.findOne({ where: { id } }); + if (!flag) return null; + + Object.assign(flag, data, { updatedBy }); + return this.flagRepository.save(flag); + } + + async deleteFlag(id: string): Promise { + const result = await this.flagRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + async softDeleteFlag(id: string, updatedBy?: string): Promise { + return this.updateFlag(id, { isActive: false }, updatedBy); + } + + async toggleFlag(id: string, enabled: boolean, updatedBy?: string): Promise { + return this.updateFlag(id, { enabled }, updatedBy); + } + + // ============================================ + // TENANT OVERRIDES - CRUD + // ============================================ + + async findOverridesForFlag(flagId: string): Promise { + return this.overrideRepository.find({ + where: { flagId }, + order: { createdAt: 'DESC' }, + }); + } + + async findOverridesForTenant(tenantId: string): Promise { + return this.overrideRepository.find({ + where: { tenantId }, + relations: ['flag'], + order: { createdAt: 'DESC' }, + }); + } + + async findOverride(flagId: string, tenantId: string): Promise { + return this.overrideRepository.findOne({ + where: { flagId, tenantId }, + relations: ['flag'], + }); + } + + async findOverrideById(id: string): Promise { + return this.overrideRepository.findOne({ + where: { id }, + relations: ['flag'], + }); + } + + async createOverride( + data: CreateTenantOverrideDto, + createdBy?: string + ): Promise { + const override = this.overrideRepository.create({ + ...data, + createdBy, + }); + return this.overrideRepository.save(override); + } + + async updateOverride( + id: string, + data: UpdateTenantOverrideDto + ): Promise { + const override = await this.overrideRepository.findOne({ where: { id } }); + if (!override) return null; + + Object.assign(override, data); + return this.overrideRepository.save(override); + } + + async deleteOverride(id: string): Promise { + const result = await this.overrideRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + async deleteOverrideByFlagAndTenant(flagId: string, tenantId: string): Promise { + const result = await this.overrideRepository.delete({ flagId, tenantId }); + return (result.affected ?? 0) > 0; + } + + // ============================================ + // FLAG EVALUATION + // ============================================ + + /** + * Evaluates a single flag for a tenant. + * Priority: tenant override > global flag > rollout > default (false) + */ + async evaluateFlag(flagCode: string, tenantId: string): Promise { + // 1. Find the flag + const flag = await this.flagRepository.findOne({ + where: { code: flagCode, isActive: true }, + }); + + if (!flag) { + return { code: flagCode, enabled: false, source: 'default' }; + } + + // 2. Check tenant override + const override = await this.overrideRepository.findOne({ + where: { flagId: flag.id, tenantId }, + }); + + if (override) { + // Check if override is expired + if (!override.expiresAt || override.expiresAt > new Date()) { + return { code: flagCode, enabled: override.enabled, source: 'override' }; + } + } + + // 3. Check global flag state + if (!flag.enabled) { + return { code: flagCode, enabled: false, source: 'global' }; + } + + // 4. Evaluate rollout percentage + if (flag.rolloutPercentage >= 100) { + return { code: flagCode, enabled: true, source: 'global' }; + } + + if (flag.rolloutPercentage <= 0) { + return { code: flagCode, enabled: false, source: 'rollout' }; + } + + // 5. Deterministic hash-based rollout + const bucket = this.calculateBucket(flagCode, tenantId); + const enabled = bucket < flag.rolloutPercentage; + + return { code: flagCode, enabled, source: 'rollout' }; + } + + /** + * Evaluates multiple flags for a tenant in a single call. + * More efficient than calling evaluateFlag multiple times. + */ + async evaluateFlags( + flagCodes: string[], + tenantId: string + ): Promise { + // Get all requested flags in one query + const flags = await this.flagRepository.find({ + where: { code: In(flagCodes), isActive: true }, + }); + + const flagMap = new Map(flags.map((f) => [f.code, f])); + + // Get all overrides for this tenant and these flags in one query + const flagIds = flags.map((f) => f.id); + const overrides = await this.overrideRepository.find({ + where: { flagId: In(flagIds), tenantId }, + }); + + const overrideMap = new Map(overrides.map((o) => [o.flagId, o])); + const now = new Date(); + + // Evaluate each flag + return flagCodes.map((code) => { + const flag = flagMap.get(code); + + if (!flag) { + return { code, enabled: false, source: 'default' as const }; + } + + const override = overrideMap.get(flag.id); + + if (override && (!override.expiresAt || override.expiresAt > now)) { + return { code, enabled: override.enabled, source: 'override' as const }; + } + + if (!flag.enabled) { + return { code, enabled: false, source: 'global' as const }; + } + + if (flag.rolloutPercentage >= 100) { + return { code, enabled: true, source: 'global' as const }; + } + + if (flag.rolloutPercentage <= 0) { + return { code, enabled: false, source: 'rollout' as const }; + } + + const bucket = this.calculateBucket(code, tenantId); + const enabled = bucket < flag.rolloutPercentage; + + return { code, enabled, source: 'rollout' as const }; + }); + } + + /** + * Quick boolean check for a single flag. + */ + async isEnabled(flagCode: string, tenantId: string): Promise { + const result = await this.evaluateFlag(flagCode, tenantId); + return result.enabled; + } + + // ============================================ + // MAINTENANCE + // ============================================ + + /** + * Removes expired overrides from the database. + * Should be called periodically via cron job. + */ + async cleanupExpiredOverrides(): Promise { + const now = new Date(); + const result = await this.overrideRepository + .createQueryBuilder() + .delete() + .where('expires_at IS NOT NULL') + .andWhere('expires_at < :now', { now }) + .execute(); + + return result.affected ?? 0; + } + + /** + * Gets statistics for a flag including override counts. + */ + async getFlagStats(flagId: string): Promise<{ + flag: Flag | null; + overrideCount: number; + enabledOverrides: number; + disabledOverrides: number; + }> { + const flag = await this.flagRepository.findOne({ where: { id: flagId } }); + + if (!flag) { + return { + flag: null, + overrideCount: 0, + enabledOverrides: 0, + disabledOverrides: 0, + }; + } + + const now = new Date(); + const overrides = await this.overrideRepository + .createQueryBuilder('o') + .where('o.flag_id = :flagId', { flagId }) + .andWhere('(o.expires_at IS NULL OR o.expires_at > :now)', { now }) + .getMany(); + + const enabledOverrides = overrides.filter((o) => o.enabled).length; + const disabledOverrides = overrides.filter((o) => !o.enabled).length; + + return { + flag, + overrideCount: overrides.length, + enabledOverrides, + disabledOverrides, + }; + } + + // ============================================ + // PRIVATE HELPERS + // ============================================ + + /** + * Calculates a deterministic bucket (0-99) for rollout evaluation. + * Uses MD5 hash of flag code + tenant ID for consistent results. + */ + private calculateBucket(flagCode: string, tenantId: string): number { + const input = `${flagCode}:${tenantId}`; + const hash = createHash('md5').update(input).digest('hex'); + // Take first 8 chars of hash and convert to number + const num = parseInt(hash.substring(0, 8), 16); + return Math.abs(num % 100); + } +} diff --git a/src/modules/feature-flags/services/index.ts b/src/modules/feature-flags/services/index.ts new file mode 100644 index 0000000..4415dc0 --- /dev/null +++ b/src/modules/feature-flags/services/index.ts @@ -0,0 +1 @@ +export { FeatureFlagsService } from './feature-flags.service'; 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/controllers/index.ts b/src/modules/inventory/controllers/index.ts new file mode 100644 index 0000000..3f5eb53 --- /dev/null +++ b/src/modules/inventory/controllers/index.ts @@ -0,0 +1 @@ +export { InventoryController } from './inventory.controller'; diff --git a/src/modules/inventory/controllers/inventory.controller.ts b/src/modules/inventory/controllers/inventory.controller.ts new file mode 100644 index 0000000..b7efb39 --- /dev/null +++ b/src/modules/inventory/controllers/inventory.controller.ts @@ -0,0 +1,342 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { InventoryService } from '../services/inventory.service'; +import { + CreateStockMovementDto, + AdjustStockDto, + TransferStockDto, + ReserveStockDto, +} from '../dto'; + +export class InventoryController { + public router: Router; + + constructor(private readonly inventoryService: InventoryService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Stock Levels + this.router.get('/stock', this.getStockLevels.bind(this)); + this.router.get('/stock/product/:productId', this.getStockByProduct.bind(this)); + this.router.get('/stock/warehouse/:warehouseId', this.getStockByWarehouse.bind(this)); + this.router.get( + '/stock/available/:productId/:warehouseId', + this.getAvailableStock.bind(this) + ); + + // Movements + this.router.get('/movements', this.getMovements.bind(this)); + this.router.get('/movements/:id', this.getMovement.bind(this)); + this.router.post('/movements', this.createMovement.bind(this)); + this.router.post('/movements/:id/confirm', this.confirmMovement.bind(this)); + this.router.post('/movements/:id/cancel', this.cancelMovement.bind(this)); + + // Operations + this.router.post('/adjust', this.adjustStock.bind(this)); + this.router.post('/transfer', this.transferStock.bind(this)); + this.router.post('/reserve', this.reserveStock.bind(this)); + this.router.post('/release', this.releaseReservation.bind(this)); + } + + // ==================== Stock Levels ==================== + + private async getStockLevels(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { + productId, + warehouseId, + locationId, + lotNumber, + hasStock, + lowStock, + limit, + offset, + } = req.query; + + const result = await this.inventoryService.getStockLevels({ + tenantId, + productId: productId as string, + warehouseId: warehouseId as string, + locationId: locationId as string, + lotNumber: lotNumber as string, + hasStock: hasStock ? hasStock === 'true' : undefined, + lowStock: lowStock ? lowStock === 'true' : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async getStockByProduct(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { productId } = req.params; + const stock = await this.inventoryService.getStockByProduct(productId, tenantId); + res.json({ data: stock }); + } catch (error) { + next(error); + } + } + + private async getStockByWarehouse( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { warehouseId } = req.params; + const stock = await this.inventoryService.getStockByWarehouse(warehouseId, tenantId); + res.json({ data: stock }); + } catch (error) { + next(error); + } + } + + private async getAvailableStock(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { productId, warehouseId } = req.params; + const available = await this.inventoryService.getAvailableStock( + productId, + warehouseId, + tenantId + ); + res.json({ data: { available } }); + } catch (error) { + next(error); + } + } + + // ==================== Movements ==================== + + private async getMovements(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { + movementType, + productId, + warehouseId, + status, + referenceType, + referenceId, + fromDate, + toDate, + limit, + offset, + } = req.query; + + const result = await this.inventoryService.getMovements({ + tenantId, + movementType: movementType as string, + productId: productId as string, + warehouseId: warehouseId as string, + status: status as 'draft' | 'confirmed' | 'cancelled', + referenceType: referenceType as string, + referenceId: referenceId as string, + fromDate: fromDate ? new Date(fromDate as string) : undefined, + toDate: toDate ? new Date(toDate as string) : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async getMovement(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const movement = await this.inventoryService.getMovement(id, tenantId); + + if (!movement) { + res.status(404).json({ error: 'Movement not found' }); + return; + } + + res.json({ data: movement }); + } catch (error) { + next(error); + } + } + + private async createMovement(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: CreateStockMovementDto = req.body; + const movement = await this.inventoryService.createMovement(tenantId, dto, userId); + res.status(201).json({ data: movement }); + } catch (error) { + next(error); + } + } + + private async confirmMovement(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const movement = await this.inventoryService.confirmMovement(id, tenantId, userId); + + if (!movement) { + res.status(404).json({ error: 'Movement not found' }); + return; + } + + res.json({ data: movement }); + } catch (error) { + next(error); + } + } + + private async cancelMovement(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const movement = await this.inventoryService.cancelMovement(id, tenantId); + + if (!movement) { + res.status(404).json({ error: 'Movement not found' }); + return; + } + + res.json({ data: movement }); + } catch (error) { + next(error); + } + } + + // ==================== Operations ==================== + + private async adjustStock(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: AdjustStockDto = req.body; + const movement = await this.inventoryService.adjustStock(tenantId, dto, userId); + res.status(201).json({ data: movement }); + } catch (error) { + next(error); + } + } + + private async transferStock(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: TransferStockDto = req.body; + const movement = await this.inventoryService.transferStock(tenantId, dto, userId); + res.status(201).json({ data: movement }); + } catch (error) { + next(error); + } + } + + private async reserveStock(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: ReserveStockDto = req.body; + await this.inventoryService.reserveStock(tenantId, dto); + res.json({ success: true }); + } catch (error) { + next(error); + } + } + + private async releaseReservation( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { productId, warehouseId, quantity } = req.body; + await this.inventoryService.releaseReservation(productId, warehouseId, quantity, tenantId); + res.json({ success: true }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/inventory/dto/create-inventory.dto.ts b/src/modules/inventory/dto/create-inventory.dto.ts new file mode 100644 index 0000000..2550261 --- /dev/null +++ b/src/modules/inventory/dto/create-inventory.dto.ts @@ -0,0 +1,192 @@ +import { + IsString, + IsOptional, + IsNumber, + IsUUID, + IsDateString, + MaxLength, + IsEnum, + Min, +} from 'class-validator'; + +export class CreateStockMovementDto { + @IsEnum(['receipt', 'shipment', 'transfer', 'adjustment', 'return', 'production', 'consumption']) + movementType: + | 'receipt' + | 'shipment' + | 'transfer' + | 'adjustment' + | 'return' + | 'production' + | 'consumption'; + + @IsUUID() + productId: string; + + @IsOptional() + @IsUUID() + sourceWarehouseId?: string; + + @IsOptional() + @IsUUID() + sourceLocationId?: string; + + @IsOptional() + @IsUUID() + destWarehouseId?: string; + + @IsOptional() + @IsUUID() + destLocationId?: string; + + @IsNumber() + @Min(0) + quantity: number; + + @IsOptional() + @IsString() + @MaxLength(20) + uom?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + lotNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + serialNumber?: string; + + @IsOptional() + @IsDateString() + expiryDate?: string; + + @IsOptional() + @IsNumber() + @Min(0) + unitCost?: number; + + @IsOptional() + @IsString() + @MaxLength(30) + referenceType?: string; + + @IsOptional() + @IsUUID() + referenceId?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + referenceNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + reason?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class AdjustStockDto { + @IsUUID() + productId: string; + + @IsUUID() + warehouseId: string; + + @IsOptional() + @IsUUID() + locationId?: string; + + @IsNumber() + newQuantity: number; + + @IsOptional() + @IsString() + @MaxLength(50) + lotNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + serialNumber?: string; + + @IsString() + @MaxLength(100) + reason: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class TransferStockDto { + @IsUUID() + productId: string; + + @IsUUID() + sourceWarehouseId: string; + + @IsOptional() + @IsUUID() + sourceLocationId?: string; + + @IsUUID() + destWarehouseId: string; + + @IsOptional() + @IsUUID() + destLocationId?: string; + + @IsNumber() + @Min(0) + quantity: number; + + @IsOptional() + @IsString() + @MaxLength(50) + lotNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + serialNumber?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class ReserveStockDto { + @IsUUID() + productId: string; + + @IsUUID() + warehouseId: string; + + @IsOptional() + @IsUUID() + locationId?: string; + + @IsNumber() + @Min(0) + quantity: number; + + @IsOptional() + @IsString() + @MaxLength(50) + lotNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + referenceType?: string; + + @IsOptional() + @IsUUID() + referenceId?: string; +} diff --git a/src/modules/inventory/dto/index.ts b/src/modules/inventory/dto/index.ts new file mode 100644 index 0000000..2011421 --- /dev/null +++ b/src/modules/inventory/dto/index.ts @@ -0,0 +1,6 @@ +export { + CreateStockMovementDto, + AdjustStockDto, + TransferStockDto, + ReserveStockDto, +} from './create-inventory.dto'; diff --git a/src/modules/inventory/entities/index.ts b/src/modules/inventory/entities/index.ts new file mode 100644 index 0000000..5d42a11 --- /dev/null +++ b/src/modules/inventory/entities/index.ts @@ -0,0 +1,6 @@ +export { StockLevel } from './stock-level.entity'; +export { StockMovement } from './stock-movement.entity'; +export { InventoryCount } from './inventory-count.entity'; +export { InventoryCountLine } from './inventory-count-line.entity'; +export { TransferOrder } from './transfer-order.entity'; +export { TransferOrderLine } from './transfer-order-line.entity'; 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/inventory-count-line.entity.ts b/src/modules/inventory/entities/inventory-count-line.entity.ts new file mode 100644 index 0000000..5aa1297 --- /dev/null +++ b/src/modules/inventory/entities/inventory-count-line.entity.ts @@ -0,0 +1,56 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { InventoryCount } from './inventory-count.entity'; + +@Entity({ name: 'inventory_count_lines', schema: 'inventory' }) +export class InventoryCountLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'count_id', type: 'uuid' }) + countId: string; + + @ManyToOne(() => InventoryCount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'count_id' }) + count: InventoryCount; + + @Index() + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Index() + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; + + @Column({ name: 'system_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true }) + systemQuantity?: number; + + @Column({ name: 'counted_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true }) + countedQuantity?: number; + + // Note: difference is GENERATED in DDL, but we calculate it in app layer + @Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true }) + lotNumber?: string; + + @Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true }) + serialNumber?: string; + + @Index() + @Column({ name: 'is_counted', type: 'boolean', default: false }) + isCounted: boolean; + + @Column({ name: 'counted_at', type: 'timestamptz', nullable: true }) + countedAt?: Date; + + @Column({ name: 'counted_by', type: 'uuid', nullable: true }) + countedBy?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/inventory/entities/inventory-count.entity.ts b/src/modules/inventory/entities/inventory-count.entity.ts new file mode 100644 index 0000000..229c5f0 --- /dev/null +++ b/src/modules/inventory/entities/inventory-count.entity.ts @@ -0,0 +1,53 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'inventory_counts', schema: 'inventory' }) +export class InventoryCount { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @Column({ name: 'count_number', type: 'varchar', length: 30 }) + countNumber: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + name?: string; + + @Index() + @Column({ name: 'count_type', type: 'varchar', length: 20, default: 'full' }) + countType: 'full' | 'partial' | 'cycle' | 'spot'; + + @Column({ name: 'scheduled_date', type: 'date', nullable: true }) + scheduledDate?: Date; + + @Column({ name: 'started_at', type: 'timestamptz', nullable: true }) + startedAt?: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt?: Date; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'in_progress' | 'completed' | 'cancelled'; + + @Column({ name: 'assigned_to', type: 'uuid', nullable: true }) + assignedTo?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} 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-level.entity.ts b/src/modules/inventory/entities/stock-level.entity.ts new file mode 100644 index 0000000..7a29f95 --- /dev/null +++ b/src/modules/inventory/entities/stock-level.entity.ts @@ -0,0 +1,87 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'stock_levels', schema: 'inventory' }) +export class StockLevel { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Index() + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @Index() + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId: string; + + // Cantidades + @Column({ name: 'quantity_on_hand', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityOnHand: number; + + @Column({ name: 'quantity_reserved', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityReserved: number; + + // quantity_available es calculado en DDL como GENERATED COLUMN, lo leemos aquí + @Column({ + name: 'quantity_available', + type: 'decimal', + precision: 15, + scale: 4, + insert: false, + update: false, + }) + quantityAvailable: number; + + @Column({ name: 'quantity_incoming', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityIncoming: number; + + @Column({ name: 'quantity_outgoing', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityOutgoing: number; + + // Lote y serie + @Index() + @Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true }) + lotNumber: string; + + @Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true }) + serialNumber: string; + + @Index() + @Column({ name: 'expiry_date', type: 'date', nullable: true }) + expiryDate: Date; + + // Costo + @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, nullable: true }) + unitCost: number; + + @Column({ name: 'total_cost', type: 'decimal', precision: 15, scale: 4, nullable: true }) + totalCost: number; + + // Ultima actividad + @Column({ name: 'last_movement_at', type: 'timestamptz', nullable: true }) + lastMovementAt: Date; + + @Column({ name: 'last_count_at', type: 'timestamptz', nullable: true }) + lastCountAt: Date; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} 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-movement.entity.ts b/src/modules/inventory/entities/stock-movement.entity.ts new file mode 100644 index 0000000..424f4be --- /dev/null +++ b/src/modules/inventory/entities/stock-movement.entity.ts @@ -0,0 +1,122 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'stock_movements', schema: 'inventory' }) +export class StockMovement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Tipo de movimiento + @Index() + @Column({ name: 'movement_type', type: 'varchar', length: 20 }) + movementType: + | 'receipt' + | 'shipment' + | 'transfer' + | 'adjustment' + | 'return' + | 'production' + | 'consumption'; + + @Index() + @Column({ name: 'movement_number', type: 'varchar', length: 30 }) + movementNumber: string; + + // Producto + @Index() + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + // Origen y destino + @Index() + @Column({ name: 'source_warehouse_id', type: 'uuid', nullable: true }) + sourceWarehouseId: string; + + @Column({ name: 'source_location_id', type: 'uuid', nullable: true }) + sourceLocationId: string; + + @Index() + @Column({ name: 'dest_warehouse_id', type: 'uuid', nullable: true }) + destWarehouseId: string; + + @Column({ name: 'dest_location_id', type: 'uuid', nullable: true }) + destLocationId: string; + + // Cantidad + @Column({ type: 'decimal', precision: 15, scale: 4 }) + quantity: number; + + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + // Lote y serie + @Index() + @Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true }) + lotNumber: string; + + @Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true }) + serialNumber: string; + + @Column({ name: 'expiry_date', type: 'date', nullable: true }) + expiryDate: Date; + + // Costo + @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, nullable: true }) + unitCost: number; + + @Column({ name: 'total_cost', type: 'decimal', precision: 15, scale: 4, nullable: true }) + totalCost: number; + + // Referencia + @Index() + @Column({ name: 'reference_type', type: 'varchar', length: 30, nullable: true }) + referenceType: string; + + @Column({ name: 'reference_id', type: 'uuid', nullable: true }) + referenceId: string; + + @Column({ name: 'reference_number', type: 'varchar', length: 50, nullable: true }) + referenceNumber: string; + + // Razon (para ajustes) + @Column({ type: 'varchar', length: 100, nullable: true }) + reason: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + // Estado + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'confirmed' | 'cancelled'; + + @Column({ name: 'confirmed_at', type: 'timestamptz', nullable: true }) + confirmedAt: Date; + + @Column({ name: 'confirmed_by', type: 'uuid', nullable: true }) + confirmedBy: string; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} 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/transfer-order-line.entity.ts b/src/modules/inventory/entities/transfer-order-line.entity.ts new file mode 100644 index 0000000..a2a2133 --- /dev/null +++ b/src/modules/inventory/entities/transfer-order-line.entity.ts @@ -0,0 +1,50 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { TransferOrder } from './transfer-order.entity'; + +@Entity({ name: 'transfer_order_lines', schema: 'inventory' }) +export class TransferOrderLine { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'transfer_id', type: 'uuid' }) + transferId: string; + + @ManyToOne(() => TransferOrder, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'transfer_id' }) + transfer: TransferOrder; + + @Index() + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @Column({ name: 'source_location_id', type: 'uuid', nullable: true }) + sourceLocationId?: string; + + @Column({ name: 'dest_location_id', type: 'uuid', nullable: true }) + destLocationId?: string; + + @Column({ name: 'quantity_requested', type: 'decimal', precision: 15, scale: 4 }) + quantityRequested: number; + + @Column({ name: 'quantity_shipped', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityShipped: number; + + @Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityReceived: number; + + @Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true }) + lotNumber?: string; + + @Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true }) + serialNumber?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/inventory/entities/transfer-order.entity.ts b/src/modules/inventory/entities/transfer-order.entity.ts new file mode 100644 index 0000000..7deb1f0 --- /dev/null +++ b/src/modules/inventory/entities/transfer-order.entity.ts @@ -0,0 +1,50 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'transfer_orders', schema: 'inventory' }) +export class TransferOrder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'transfer_number', type: 'varchar', length: 30 }) + transferNumber: string; + + @Index() + @Column({ name: 'source_warehouse_id', type: 'uuid' }) + sourceWarehouseId: string; + + @Index() + @Column({ name: 'dest_warehouse_id', type: 'uuid' }) + destWarehouseId: string; + + @Column({ name: 'scheduled_date', type: 'date', nullable: true }) + scheduledDate?: Date; + + @Column({ name: 'shipped_at', type: 'timestamptz', nullable: true }) + shippedAt?: Date; + + @Column({ name: 'received_at', type: 'timestamptz', nullable: true }) + receivedAt?: Date; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'confirmed' | 'shipped' | 'in_transit' | 'received' | 'cancelled'; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt?: Date; +} 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..25f38d4 --- /dev/null +++ b/src/modules/inventory/index.ts @@ -0,0 +1,5 @@ +export { InventoryModule, InventoryModuleOptions } from './inventory.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; 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.module.ts b/src/modules/inventory/inventory.module.ts new file mode 100644 index 0000000..178a301 --- /dev/null +++ b/src/modules/inventory/inventory.module.ts @@ -0,0 +1,45 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { InventoryService } from './services'; +import { InventoryController } from './controllers'; +import { StockLevel, StockMovement } from './entities'; + +export interface InventoryModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class InventoryModule { + public router: Router; + public inventoryService: InventoryService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: InventoryModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const stockLevelRepository = this.dataSource.getRepository(StockLevel); + const movementRepository = this.dataSource.getRepository(StockMovement); + + this.inventoryService = new InventoryService( + stockLevelRepository, + movementRepository, + this.dataSource + ); + } + + private initializeRoutes(): void { + const inventoryController = new InventoryController(this.inventoryService); + this.router.use(`${this.basePath}/inventory`, inventoryController.router); + } + + static getEntities(): Function[] { + return [StockLevel, StockMovement]; + } +} 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/services/index.ts b/src/modules/inventory/services/index.ts new file mode 100644 index 0000000..d9ca5a2 --- /dev/null +++ b/src/modules/inventory/services/index.ts @@ -0,0 +1,5 @@ +export { + InventoryService, + StockSearchParams, + MovementSearchParams, +} from './inventory.service'; diff --git a/src/modules/inventory/services/inventory.service.ts b/src/modules/inventory/services/inventory.service.ts new file mode 100644 index 0000000..7a08332 --- /dev/null +++ b/src/modules/inventory/services/inventory.service.ts @@ -0,0 +1,470 @@ +import { Repository, FindOptionsWhere, ILike, DataSource } from 'typeorm'; +import { StockLevel, StockMovement } from '../entities'; +import { + CreateStockMovementDto, + AdjustStockDto, + TransferStockDto, + ReserveStockDto, +} from '../dto'; + +export interface StockSearchParams { + tenantId: string; + productId?: string; + warehouseId?: string; + locationId?: string; + lotNumber?: string; + hasStock?: boolean; + lowStock?: boolean; + limit?: number; + offset?: number; +} + +export interface MovementSearchParams { + tenantId: string; + movementType?: string; + productId?: string; + warehouseId?: string; + status?: 'draft' | 'confirmed' | 'cancelled'; + referenceType?: string; + referenceId?: string; + fromDate?: Date; + toDate?: Date; + limit?: number; + offset?: number; +} + +export class InventoryService { + constructor( + private readonly stockLevelRepository: Repository, + private readonly movementRepository: Repository, + private readonly dataSource: DataSource + ) {} + + // ==================== Stock Levels ==================== + + async getStockLevels( + params: StockSearchParams + ): Promise<{ data: StockLevel[]; total: number }> { + const { + tenantId, + productId, + warehouseId, + locationId, + lotNumber, + hasStock, + lowStock, + limit = 50, + offset = 0, + } = params; + + const qb = this.stockLevelRepository + .createQueryBuilder('stock') + .where('stock.tenant_id = :tenantId', { tenantId }); + + if (productId) { + qb.andWhere('stock.product_id = :productId', { productId }); + } + + if (warehouseId) { + qb.andWhere('stock.warehouse_id = :warehouseId', { warehouseId }); + } + + if (locationId) { + qb.andWhere('stock.location_id = :locationId', { locationId }); + } + + if (lotNumber) { + qb.andWhere('stock.lot_number = :lotNumber', { lotNumber }); + } + + if (hasStock) { + qb.andWhere('stock.quantity_on_hand > 0'); + } + + if (lowStock) { + qb.andWhere('stock.quantity_on_hand <= 0'); + } + + const [data, total] = await qb + .orderBy('stock.product_id', 'ASC') + .addOrderBy('stock.warehouse_id', 'ASC') + .take(limit) + .skip(offset) + .getManyAndCount(); + + return { data, total }; + } + + async getStockByProduct( + productId: string, + tenantId: string + ): Promise { + return this.stockLevelRepository.find({ + where: { productId, tenantId }, + order: { warehouseId: 'ASC' }, + }); + } + + async getStockByWarehouse( + warehouseId: string, + tenantId: string + ): Promise { + return this.stockLevelRepository.find({ + where: { warehouseId, tenantId }, + order: { productId: 'ASC' }, + }); + } + + async getAvailableStock( + productId: string, + warehouseId: string, + tenantId: string + ): Promise { + const stock = await this.stockLevelRepository.findOne({ + where: { productId, warehouseId, tenantId }, + }); + return stock?.quantityAvailable ?? 0; + } + + // ==================== Stock Movements ==================== + + async getMovements( + params: MovementSearchParams + ): Promise<{ data: StockMovement[]; total: number }> { + const { + tenantId, + movementType, + productId, + warehouseId, + status, + referenceType, + referenceId, + fromDate, + toDate, + limit = 50, + offset = 0, + } = params; + + const qb = this.movementRepository + .createQueryBuilder('movement') + .where('movement.tenant_id = :tenantId', { tenantId }); + + if (movementType) { + qb.andWhere('movement.movement_type = :movementType', { movementType }); + } + + if (productId) { + qb.andWhere('movement.product_id = :productId', { productId }); + } + + if (warehouseId) { + qb.andWhere( + '(movement.source_warehouse_id = :warehouseId OR movement.dest_warehouse_id = :warehouseId)', + { warehouseId } + ); + } + + if (status) { + qb.andWhere('movement.status = :status', { status }); + } + + if (referenceType) { + qb.andWhere('movement.reference_type = :referenceType', { referenceType }); + } + + if (referenceId) { + qb.andWhere('movement.reference_id = :referenceId', { referenceId }); + } + + if (fromDate) { + qb.andWhere('movement.created_at >= :fromDate', { fromDate }); + } + + if (toDate) { + qb.andWhere('movement.created_at <= :toDate', { toDate }); + } + + const [data, total] = await qb + .orderBy('movement.created_at', 'DESC') + .take(limit) + .skip(offset) + .getManyAndCount(); + + return { data, total }; + } + + async getMovement(id: string, tenantId: string): Promise { + return this.movementRepository.findOne({ where: { id, tenantId } }); + } + + async createMovement( + tenantId: string, + dto: CreateStockMovementDto, + createdBy?: string + ): Promise { + // Generate movement number + const count = await this.movementRepository.count({ where: { tenantId } }); + const movementNumber = `MOV-${String(count + 1).padStart(6, '0')}`; + + const totalCost = dto.unitCost ? dto.unitCost * dto.quantity : undefined; + + const movement = this.movementRepository.create({ + ...dto, + tenantId, + movementNumber, + totalCost, + expiryDate: dto.expiryDate ? new Date(dto.expiryDate) : undefined, + createdBy, + }); + + return this.movementRepository.save(movement); + } + + async confirmMovement( + id: string, + tenantId: string, + confirmedBy: string + ): Promise { + const movement = await this.getMovement(id, tenantId); + if (!movement) return null; + + if (movement.status !== 'draft') { + throw new Error('Only draft movements can be confirmed'); + } + + // Update stock levels based on movement type + await this.applyMovementToStock(movement); + + movement.status = 'confirmed'; + movement.confirmedAt = new Date(); + movement.confirmedBy = confirmedBy; + + return this.movementRepository.save(movement); + } + + async cancelMovement(id: string, tenantId: string): Promise { + const movement = await this.getMovement(id, tenantId); + if (!movement) return null; + + if (movement.status === 'confirmed') { + throw new Error('Cannot cancel confirmed movement'); + } + + movement.status = 'cancelled'; + return this.movementRepository.save(movement); + } + + // ==================== Stock Operations ==================== + + async adjustStock( + tenantId: string, + dto: AdjustStockDto, + userId?: string + ): Promise { + const currentStock = await this.getStockLevel( + dto.productId, + dto.warehouseId, + dto.locationId, + dto.lotNumber, + dto.serialNumber, + tenantId + ); + + const currentQuantity = currentStock?.quantityOnHand ?? 0; + const difference = dto.newQuantity - currentQuantity; + + const movement = await this.createMovement( + tenantId, + { + movementType: 'adjustment', + productId: dto.productId, + destWarehouseId: dto.warehouseId, + destLocationId: dto.locationId, + quantity: Math.abs(difference), + lotNumber: dto.lotNumber, + serialNumber: dto.serialNumber, + reason: dto.reason, + notes: dto.notes, + }, + userId + ); + + return this.confirmMovement(movement.id, tenantId, userId || '') as Promise; + } + + async transferStock( + tenantId: string, + dto: TransferStockDto, + userId?: string + ): Promise { + // Verify available stock + const available = await this.getAvailableStock( + dto.productId, + dto.sourceWarehouseId, + tenantId + ); + + if (available < dto.quantity) { + throw new Error('Insufficient stock for transfer'); + } + + const movement = await this.createMovement( + tenantId, + { + movementType: 'transfer', + productId: dto.productId, + sourceWarehouseId: dto.sourceWarehouseId, + sourceLocationId: dto.sourceLocationId, + destWarehouseId: dto.destWarehouseId, + destLocationId: dto.destLocationId, + quantity: dto.quantity, + lotNumber: dto.lotNumber, + serialNumber: dto.serialNumber, + notes: dto.notes, + }, + userId + ); + + return this.confirmMovement(movement.id, tenantId, userId || '') as Promise; + } + + async reserveStock(tenantId: string, dto: ReserveStockDto): Promise { + const stock = await this.getStockLevel( + dto.productId, + dto.warehouseId, + dto.locationId, + dto.lotNumber, + undefined, + tenantId + ); + + if (!stock || stock.quantityAvailable < dto.quantity) { + throw new Error('Insufficient available stock for reservation'); + } + + stock.quantityReserved = Number(stock.quantityReserved) + dto.quantity; + await this.stockLevelRepository.save(stock); + + return true; + } + + async releaseReservation( + productId: string, + warehouseId: string, + quantity: number, + tenantId: string + ): Promise { + const stock = await this.stockLevelRepository.findOne({ + where: { productId, warehouseId, tenantId }, + }); + + if (!stock) return false; + + stock.quantityReserved = Math.max(0, Number(stock.quantityReserved) - quantity); + await this.stockLevelRepository.save(stock); + + return true; + } + + // ==================== Private Methods ==================== + + private async getStockLevel( + productId: string, + warehouseId: string, + locationId: string | undefined, + lotNumber: string | undefined, + serialNumber: string | undefined, + tenantId: string + ): Promise { + const where: FindOptionsWhere = { + productId, + warehouseId, + tenantId, + }; + + if (locationId) where.locationId = locationId; + if (lotNumber) where.lotNumber = lotNumber; + if (serialNumber) where.serialNumber = serialNumber; + + return this.stockLevelRepository.findOne({ where }); + } + + private async applyMovementToStock(movement: StockMovement): Promise { + const { movementType, productId, quantity, sourceWarehouseId, destWarehouseId, lotNumber } = + movement; + + // Decrease source stock + if (sourceWarehouseId && ['shipment', 'transfer', 'consumption'].includes(movementType)) { + await this.updateStockLevel( + productId, + sourceWarehouseId, + movement.sourceLocationId, + lotNumber, + movement.serialNumber, + movement.tenantId, + -quantity + ); + } + + // Increase destination stock + if (destWarehouseId && ['receipt', 'transfer', 'adjustment', 'return', 'production'].includes(movementType)) { + await this.updateStockLevel( + productId, + destWarehouseId, + movement.destLocationId, + lotNumber, + movement.serialNumber, + movement.tenantId, + quantity, + movement.unitCost + ); + } + } + + private async updateStockLevel( + productId: string, + warehouseId: string, + locationId: string | null, + lotNumber: string | null, + serialNumber: string | null, + tenantId: string, + quantityChange: number, + unitCost?: number + ): Promise { + let stock = await this.stockLevelRepository.findOne({ + where: { + productId, + warehouseId, + locationId: locationId || undefined, + lotNumber: lotNumber || undefined, + serialNumber: serialNumber || undefined, + tenantId, + }, + }); + + if (!stock) { + stock = this.stockLevelRepository.create({ + productId, + warehouseId, + locationId: locationId || undefined, + lotNumber: lotNumber || undefined, + serialNumber: serialNumber || undefined, + tenantId, + quantityOnHand: 0, + quantityReserved: 0, + quantityIncoming: 0, + quantityOutgoing: 0, + } as Partial); + } + + stock.quantityOnHand = Number(stock.quantityOnHand) + quantityChange; + stock.lastMovementAt = new Date(); + + if (unitCost !== undefined) { + stock.unitCost = unitCost; + stock.totalCost = stock.quantityOnHand * unitCost; + } + + await this.stockLevelRepository.save(stock); + } +} 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/invoices/controllers/index.ts b/src/modules/invoices/controllers/index.ts new file mode 100644 index 0000000..8eec907 --- /dev/null +++ b/src/modules/invoices/controllers/index.ts @@ -0,0 +1,129 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { InvoicesService } from '../services'; + +export class InvoicesController { + public router: Router; + constructor(private readonly invoicesService: InvoicesService) { + this.router = Router(); + this.router.get('/', this.findAll.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + this.router.post('/:id/validate', this.validate.bind(this)); + } + + private async findAll(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const { invoiceType, partnerId, status, limit, offset } = req.query; + const result = await this.invoicesService.findAllInvoices({ tenantId, invoiceType: invoiceType as string, partnerId: partnerId as string, status: status as string, limit: limit ? parseInt(limit as string) : undefined, offset: offset ? parseInt(offset as string) : undefined }); + res.json(result); + } catch (e) { next(e); } + } + + private async findOne(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const invoice = await this.invoicesService.findInvoice(req.params.id, tenantId); + if (!invoice) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: invoice }); + } catch (e) { next(e); } + } + + private async create(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const invoice = await this.invoicesService.createInvoice(tenantId, req.body, userId); + res.status(201).json({ data: invoice }); + } catch (e) { next(e); } + } + + private async update(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const invoice = await this.invoicesService.updateInvoice(req.params.id, tenantId, req.body, userId); + if (!invoice) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: invoice }); + } catch (e) { next(e); } + } + + private async delete(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const deleted = await this.invoicesService.deleteInvoice(req.params.id, tenantId); + if (!deleted) { res.status(404).json({ error: 'Not found' }); return; } + res.status(204).send(); + } catch (e) { next(e); } + } + + private async validate(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const invoice = await this.invoicesService.validateInvoice(req.params.id, tenantId, userId); + if (!invoice) { res.status(400).json({ error: 'Cannot validate' }); return; } + res.json({ data: invoice }); + } catch (e) { next(e); } + } +} + +export class PaymentsController { + public router: Router; + constructor(private readonly invoicesService: InvoicesService) { + this.router = Router(); + this.router.get('/', this.findAll.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.post('/:id/confirm', this.confirm.bind(this)); + } + + private async findAll(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const { partnerId, limit, offset } = req.query; + const result = await this.invoicesService.findAllPayments(tenantId, partnerId as string, limit ? parseInt(limit as string) : undefined, offset ? parseInt(offset as string) : undefined); + res.json(result); + } catch (e) { next(e); } + } + + private async findOne(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const payment = await this.invoicesService.findPayment(req.params.id, tenantId); + if (!payment) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: payment }); + } catch (e) { next(e); } + } + + private async create(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const payment = await this.invoicesService.createPayment(tenantId, req.body, userId); + res.status(201).json({ data: payment }); + } catch (e) { next(e); } + } + + private async confirm(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const payment = await this.invoicesService.confirmPayment(req.params.id, tenantId, userId); + if (!payment) { res.status(400).json({ error: 'Cannot confirm' }); return; } + res.json({ data: payment }); + } catch (e) { next(e); } + } +} diff --git a/src/modules/invoices/dto/index.ts b/src/modules/invoices/dto/index.ts new file mode 100644 index 0000000..0fd6a03 --- /dev/null +++ b/src/modules/invoices/dto/index.ts @@ -0,0 +1,59 @@ +import { IsString, IsOptional, IsNumber, IsUUID, IsDateString, IsArray, IsObject, MaxLength, IsEnum, Min } from 'class-validator'; + +export class CreateInvoiceDto { + @IsEnum(['sale', 'purchase', 'credit_note', 'debit_note']) invoiceType: 'sale' | 'purchase' | 'credit_note' | 'debit_note'; + @IsUUID() partnerId: string; + @IsOptional() @IsString() @MaxLength(200) partnerName?: string; + @IsOptional() @IsString() @MaxLength(50) partnerTaxId?: string; + @IsOptional() @IsUUID() salesOrderId?: string; + @IsOptional() @IsUUID() purchaseOrderId?: string; + @IsOptional() @IsObject() billingAddress?: object; + @IsOptional() @IsDateString() invoiceDate?: string; + @IsOptional() @IsDateString() dueDate?: string; + @IsOptional() @IsString() @MaxLength(3) currency?: string; + @IsOptional() @IsNumber() exchangeRate?: number; + @IsOptional() @IsNumber() paymentTermDays?: number; + @IsOptional() @IsString() paymentMethod?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsArray() items?: CreateInvoiceItemDto[]; +} + +export class CreateInvoiceItemDto { + @IsOptional() @IsUUID() productId?: string; + @IsString() @MaxLength(200) productName: string; + @IsOptional() @IsString() @MaxLength(50) productSku?: string; + @IsOptional() @IsString() @MaxLength(20) satProductCode?: string; + @IsOptional() @IsString() @MaxLength(10) satUnitCode?: string; + @IsNumber() @Min(0) quantity: number; + @IsOptional() @IsString() @MaxLength(20) uom?: string; + @IsNumber() @Min(0) unitPrice: number; + @IsOptional() @IsNumber() discountPercent?: number; + @IsOptional() @IsNumber() taxRate?: number; +} + +export class UpdateInvoiceDto { + @IsOptional() @IsDateString() dueDate?: string; + @IsOptional() @IsString() paymentMethod?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsEnum(['draft', 'validated', 'sent', 'partial', 'paid', 'cancelled', 'voided']) status?: string; +} + +export class CreatePaymentDto { + @IsEnum(['received', 'made']) paymentType: 'received' | 'made'; + @IsUUID() partnerId: string; + @IsOptional() @IsString() @MaxLength(200) partnerName?: string; + @IsNumber() @Min(0) amount: number; + @IsOptional() @IsString() @MaxLength(3) currency?: string; + @IsOptional() @IsNumber() exchangeRate?: number; + @IsOptional() @IsDateString() paymentDate?: string; + @IsString() @MaxLength(50) paymentMethod: string; + @IsOptional() @IsString() @MaxLength(100) reference?: string; + @IsOptional() @IsUUID() bankAccountId?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsArray() invoiceAllocations?: InvoiceAllocationDto[]; +} + +export class InvoiceAllocationDto { + @IsUUID() invoiceId: string; + @IsNumber() @Min(0) amount: number; +} diff --git a/src/modules/invoices/entities/index.ts b/src/modules/invoices/entities/index.ts new file mode 100644 index 0000000..4e9cd0d --- /dev/null +++ b/src/modules/invoices/entities/index.ts @@ -0,0 +1,4 @@ +export { Invoice } from './invoice.entity'; +export { InvoiceItem } from './invoice-item.entity'; +export { Payment } from './payment.entity'; +export { PaymentAllocation } from './payment-allocation.entity'; diff --git a/src/modules/invoices/entities/invoice-item.entity.ts b/src/modules/invoices/entities/invoice-item.entity.ts new file mode 100644 index 0000000..38dc4cd --- /dev/null +++ b/src/modules/invoices/entities/invoice-item.entity.ts @@ -0,0 +1,78 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Invoice } from './invoice.entity'; + +@Entity({ name: 'invoice_items', schema: 'billing' }) +export class InvoiceItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'invoice_id', type: 'uuid' }) + invoiceId: string; + + @ManyToOne(() => Invoice, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'invoice_id' }) + invoice: Invoice; + + @Index() + @Column({ name: 'product_id', type: 'uuid', nullable: true }) + productId?: string; + + @Column({ name: 'line_number', type: 'int', default: 1 }) + lineNumber: number; + + @Column({ name: 'product_sku', type: 'varchar', length: 50, nullable: true }) + productSku?: string; + + @Column({ name: 'product_name', type: 'varchar', length: 200 }) + productName: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + // SAT (Mexico) + @Column({ name: 'sat_product_code', type: 'varchar', length: 20, nullable: true }) + satProductCode?: string; + + @Column({ name: 'sat_unit_code', type: 'varchar', length: 10, nullable: true }) + satUnitCode?: string; + + @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) + quantity: number; + + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + unitPrice: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16.00 }) + taxRate: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'withholding_rate', type: 'decimal', precision: 5, scale: 2, default: 0 }) + withholdingRate: number; + + @Column({ name: 'withholding_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + withholdingAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/invoices/entities/invoice.entity.ts b/src/modules/invoices/entities/invoice.entity.ts new file mode 100644 index 0000000..13ee790 --- /dev/null +++ b/src/modules/invoices/entities/invoice.entity.ts @@ -0,0 +1,118 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'invoices', schema: 'billing' }) +export class Invoice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'invoice_number', type: 'varchar', length: 30 }) + invoiceNumber: string; + + @Index() + @Column({ name: 'invoice_type', type: 'varchar', length: 20, default: 'sale' }) + invoiceType: 'sale' | 'purchase' | 'credit_note' | 'debit_note'; + + @Column({ name: 'sales_order_id', type: 'uuid', nullable: true }) + salesOrderId: string; + + @Column({ name: 'purchase_order_id', type: 'uuid', nullable: true }) + purchaseOrderId: string; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @Column({ name: 'partner_name', type: 'varchar', length: 200, nullable: true }) + partnerName: string; + + @Column({ name: 'partner_tax_id', type: 'varchar', length: 50, nullable: true }) + partnerTaxId: string; + + @Column({ name: 'billing_address', type: 'jsonb', nullable: true }) + billingAddress: object; + + @Column({ name: 'invoice_date', type: 'date', default: () => 'CURRENT_DATE' }) + invoiceDate: Date; + + @Column({ name: 'due_date', type: 'date', nullable: true }) + dueDate: Date; + + @Column({ name: 'payment_date', type: 'date', nullable: true }) + paymentDate: Date; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) + exchangeRate: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'withholding_tax', type: 'decimal', precision: 15, scale: 2, default: 0 }) + withholdingTax: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'amount_paid', type: 'decimal', precision: 15, scale: 2, default: 0 }) + amountPaid: number; + + @Column({ name: 'amount_due', type: 'decimal', precision: 15, scale: 2, insert: false, update: false }) + amountDue: number; + + @Column({ name: 'payment_term_days', type: 'int', default: 0 }) + paymentTermDays: number; + + @Column({ name: 'payment_method', type: 'varchar', length: 50, nullable: true }) + paymentMethod: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'validated' | 'sent' | 'partial' | 'paid' | 'cancelled' | 'voided'; + + @Index() + @Column({ name: 'cfdi_uuid', type: 'varchar', length: 40, nullable: true }) + cfdiUuid: string; + + @Column({ name: 'cfdi_status', type: 'varchar', length: 20, nullable: true }) + cfdiStatus: string; + + @Column({ name: 'cfdi_xml', type: 'text', nullable: true }) + cfdiXml: string; + + @Column({ name: 'cfdi_pdf_url', type: 'varchar', length: 500, nullable: true }) + cfdiPdfUrl: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'internal_notes', type: 'text', nullable: true }) + internalNotes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/invoices/entities/payment-allocation.entity.ts b/src/modules/invoices/entities/payment-allocation.entity.ts new file mode 100644 index 0000000..9917804 --- /dev/null +++ b/src/modules/invoices/entities/payment-allocation.entity.ts @@ -0,0 +1,37 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Payment } from './payment.entity'; +import { Invoice } from './invoice.entity'; + +@Entity({ name: 'payment_allocations', schema: 'billing' }) +export class PaymentAllocation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'payment_id', type: 'uuid' }) + paymentId: string; + + @ManyToOne(() => Payment, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'payment_id' }) + payment: Payment; + + @Index() + @Column({ name: 'invoice_id', type: 'uuid' }) + invoiceId: string; + + @ManyToOne(() => Invoice, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'invoice_id' }) + invoice: Invoice; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + amount: number; + + @Column({ name: 'allocation_date', type: 'date', default: () => 'CURRENT_DATE' }) + allocationDate: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; +} diff --git a/src/modules/invoices/entities/payment.entity.ts b/src/modules/invoices/entities/payment.entity.ts new file mode 100644 index 0000000..83ca2fa --- /dev/null +++ b/src/modules/invoices/entities/payment.entity.ts @@ -0,0 +1,73 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'payments', schema: 'billing' }) +export class Payment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'payment_number', type: 'varchar', length: 30 }) + paymentNumber: string; + + @Index() + @Column({ name: 'payment_type', type: 'varchar', length: 20, default: 'received' }) + paymentType: 'received' | 'made'; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @Column({ name: 'partner_name', type: 'varchar', length: 200, nullable: true }) + partnerName: string; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ type: 'decimal', precision: 15, scale: 2 }) + amount: number; + + @Column({ name: 'exchange_rate', type: 'decimal', precision: 10, scale: 6, default: 1 }) + exchangeRate: number; + + @Column({ name: 'payment_date', type: 'date', default: () => 'CURRENT_DATE' }) + paymentDate: Date; + + @Index() + @Column({ name: 'payment_method', type: 'varchar', length: 50 }) + paymentMethod: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + reference: string; + + @Column({ name: 'bank_account_id', type: 'uuid', nullable: true }) + bankAccountId: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'confirmed' | 'reconciled' | 'cancelled'; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'cfdi_uuid', type: 'varchar', length: 40, nullable: true }) + cfdiUuid: string; + + @Column({ name: 'cfdi_status', type: 'varchar', length: 20, nullable: true }) + cfdiStatus: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/invoices/index.ts b/src/modules/invoices/index.ts new file mode 100644 index 0000000..48f9e2f --- /dev/null +++ b/src/modules/invoices/index.ts @@ -0,0 +1,5 @@ +export { InvoicesModule, InvoicesModuleOptions } from './invoices.module'; +export * from './entities'; +export { InvoicesService } from './services'; +export { InvoicesController, PaymentsController } from './controllers'; +export * from './dto'; diff --git a/src/modules/invoices/invoices.module.ts b/src/modules/invoices/invoices.module.ts new file mode 100644 index 0000000..08409ab --- /dev/null +++ b/src/modules/invoices/invoices.module.ts @@ -0,0 +1,42 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { InvoicesService } from './services'; +import { InvoicesController, PaymentsController } from './controllers'; +import { Invoice, Payment } from './entities'; + +export interface InvoicesModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class InvoicesModule { + public router: Router; + public invoicesService: InvoicesService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: InvoicesModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const invoiceRepository = this.dataSource.getRepository(Invoice); + const paymentRepository = this.dataSource.getRepository(Payment); + this.invoicesService = new InvoicesService(invoiceRepository, paymentRepository); + } + + private initializeRoutes(): void { + const invoicesController = new InvoicesController(this.invoicesService); + const paymentsController = new PaymentsController(this.invoicesService); + this.router.use(`${this.basePath}/invoices`, invoicesController.router); + this.router.use(`${this.basePath}/payments`, paymentsController.router); + } + + static getEntities(): Function[] { + return [Invoice, Payment]; + } +} diff --git a/src/modules/invoices/services/index.ts b/src/modules/invoices/services/index.ts new file mode 100644 index 0000000..f41b370 --- /dev/null +++ b/src/modules/invoices/services/index.ts @@ -0,0 +1,86 @@ +import { Repository, FindOptionsWhere } from 'typeorm'; +import { Invoice, Payment } from '../entities'; +import { CreateInvoiceDto, UpdateInvoiceDto, CreatePaymentDto } from '../dto'; + +export interface InvoiceSearchParams { + tenantId: string; + invoiceType?: string; + partnerId?: string; + status?: string; + limit?: number; + offset?: number; +} + +export class InvoicesService { + constructor( + private readonly invoiceRepository: Repository, + private readonly paymentRepository: Repository + ) {} + + async findAllInvoices(params: InvoiceSearchParams): Promise<{ data: Invoice[]; total: number }> { + const { tenantId, invoiceType, partnerId, status, limit = 50, offset = 0 } = params; + const where: FindOptionsWhere = { tenantId }; + if (invoiceType) where.invoiceType = invoiceType as any; + if (partnerId) where.partnerId = partnerId; + if (status) where.status = status as any; + const [data, total] = await this.invoiceRepository.findAndCount({ where, take: limit, skip: offset, order: { createdAt: 'DESC' } }); + return { data, total }; + } + + async findInvoice(id: string, tenantId: string): Promise { + return this.invoiceRepository.findOne({ where: { id, tenantId } }); + } + + async createInvoice(tenantId: string, dto: CreateInvoiceDto, createdBy?: string): Promise { + const count = await this.invoiceRepository.count({ where: { tenantId, invoiceType: dto.invoiceType } }); + const prefix = dto.invoiceType === 'sale' ? 'FAC' : dto.invoiceType === 'purchase' ? 'FP' : dto.invoiceType === 'credit_note' ? 'NC' : 'ND'; + const invoiceNumber = `${prefix}-${String(count + 1).padStart(6, '0')}`; + const invoice = this.invoiceRepository.create({ ...dto, tenantId, invoiceNumber, createdBy, invoiceDate: dto.invoiceDate ? new Date(dto.invoiceDate) : new Date(), dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined }); + return this.invoiceRepository.save(invoice); + } + + async updateInvoice(id: string, tenantId: string, dto: UpdateInvoiceDto, updatedBy?: string): Promise { + const invoice = await this.findInvoice(id, tenantId); + if (!invoice) return null; + Object.assign(invoice, { ...dto, updatedBy }); + return this.invoiceRepository.save(invoice); + } + + async deleteInvoice(id: string, tenantId: string): Promise { + const result = await this.invoiceRepository.softDelete({ id, tenantId }); + return (result.affected ?? 0) > 0; + } + + async validateInvoice(id: string, tenantId: string, userId?: string): Promise { + const invoice = await this.findInvoice(id, tenantId); + if (!invoice || invoice.status !== 'draft') return null; + invoice.status = 'validated'; + invoice.updatedBy = userId; + return this.invoiceRepository.save(invoice); + } + + async findAllPayments(tenantId: string, partnerId?: string, limit = 50, offset = 0): Promise<{ data: Payment[]; total: number }> { + const where: FindOptionsWhere = { tenantId }; + if (partnerId) where.partnerId = partnerId; + const [data, total] = await this.paymentRepository.findAndCount({ where, take: limit, skip: offset, order: { createdAt: 'DESC' } }); + return { data, total }; + } + + async findPayment(id: string, tenantId: string): Promise { + return this.paymentRepository.findOne({ where: { id, tenantId } }); + } + + async createPayment(tenantId: string, dto: CreatePaymentDto, createdBy?: string): Promise { + const count = await this.paymentRepository.count({ where: { tenantId } }); + const paymentNumber = `PAG-${String(count + 1).padStart(6, '0')}`; + const payment = this.paymentRepository.create({ ...dto, tenantId, paymentNumber, createdBy, paymentDate: dto.paymentDate ? new Date(dto.paymentDate) : new Date() }); + return this.paymentRepository.save(payment); + } + + async confirmPayment(id: string, tenantId: string, userId?: string): Promise { + const payment = await this.findPayment(id, tenantId); + if (!payment || payment.status !== 'draft') return null; + payment.status = 'confirmed'; + return this.paymentRepository.save(payment); + } +} diff --git a/src/modules/mcp/controllers/index.ts b/src/modules/mcp/controllers/index.ts new file mode 100644 index 0000000..452c198 --- /dev/null +++ b/src/modules/mcp/controllers/index.ts @@ -0,0 +1 @@ +export { McpController } from './mcp.controller'; diff --git a/src/modules/mcp/controllers/mcp.controller.ts b/src/modules/mcp/controllers/mcp.controller.ts new file mode 100644 index 0000000..306ae92 --- /dev/null +++ b/src/modules/mcp/controllers/mcp.controller.ts @@ -0,0 +1,223 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { McpServerService } from '../services/mcp-server.service'; +import { McpContext, CallerType } from '../interfaces'; + +export class McpController { + public router: Router; + + constructor(private readonly mcpService: McpServerService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Tools + this.router.get('/tools', this.listTools.bind(this)); + this.router.get('/tools/:name', this.getTool.bind(this)); + this.router.post('/tools/call', this.callTool.bind(this)); + + // Resources + this.router.get('/resources', this.listResources.bind(this)); + this.router.get('/resources/*', this.getResource.bind(this)); + + // History / Audit + this.router.get('/tool-calls', this.getToolCallHistory.bind(this)); + this.router.get('/tool-calls/:id', this.getToolCallDetails.bind(this)); + this.router.get('/stats', this.getToolStats.bind(this)); + } + + // ============================================ + // TOOLS + // ============================================ + + private async listTools(req: Request, res: Response, next: NextFunction): Promise { + try { + const tools = this.mcpService.listTools(); + res.json({ data: tools, total: tools.length }); + } catch (error) { + next(error); + } + } + + private async getTool(req: Request, res: Response, next: NextFunction): Promise { + try { + const { name } = req.params; + const tool = this.mcpService.getTool(name); + + if (!tool) { + res.status(404).json({ error: 'Tool not found' }); + return; + } + + res.json({ data: tool }); + } catch (error) { + next(error); + } + } + + private async callTool(req: Request, res: Response, next: NextFunction): Promise { + try { + const { tool, parameters } = req.body; + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const agentId = req.headers['x-agent-id'] as string; + const conversationId = req.headers['x-conversation-id'] as string; + + if (!tool) { + res.status(400).json({ error: 'tool name is required' }); + return; + } + + if (!tenantId) { + res.status(400).json({ error: 'x-tenant-id header is required' }); + return; + } + + const context: McpContext = { + tenantId, + userId, + agentId, + conversationId, + callerType: (req.headers['x-caller-type'] as CallerType) || 'api', + permissions: this.extractPermissions(req), + }; + + const result = await this.mcpService.callTool(tool, parameters || {}, context); + res.json({ data: result }); + } catch (error) { + next(error); + } + } + + // ============================================ + // RESOURCES + // ============================================ + + private async listResources(req: Request, res: Response, next: NextFunction): Promise { + try { + const resources = this.mcpService.listResources(); + res.json({ data: resources, total: resources.length }); + } catch (error) { + next(error); + } + } + + private async getResource(req: Request, res: Response, next: NextFunction): Promise { + try { + const uri = 'erp://' + req.params[0]; + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'x-tenant-id header is required' }); + return; + } + + const context: McpContext = { + tenantId, + userId, + callerType: 'api', + permissions: this.extractPermissions(req), + }; + + const content = await this.mcpService.getResource(uri, context); + res.json({ data: { uri, content } }); + } catch (error: any) { + if (error.message.includes('not found')) { + res.status(404).json({ error: error.message }); + return; + } + next(error); + } + } + + // ============================================ + // HISTORY / AUDIT + // ============================================ + + private async getToolCallHistory(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'x-tenant-id header is required' }); + return; + } + + const filters = { + toolName: req.query.toolName as string, + status: req.query.status as any, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + page: parseInt(req.query.page as string) || 1, + limit: Math.min(parseInt(req.query.limit as string) || 20, 100), + }; + + const result = await this.mcpService.getCallHistory(tenantId, filters); + res.json(result); + } catch (error) { + next(error); + } + } + + private async getToolCallDetails(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'x-tenant-id header is required' }); + return; + } + + const call = await this.mcpService.getCallDetails(id, tenantId); + + if (!call) { + res.status(404).json({ error: 'Tool call not found' }); + return; + } + + res.json({ data: call }); + } catch (error) { + next(error); + } + } + + private async getToolStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'x-tenant-id header is required' }); + return; + } + + const startDate = req.query.startDate + ? new Date(req.query.startDate as string) + : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // 7 days ago + const endDate = req.query.endDate + ? new Date(req.query.endDate as string) + : new Date(); + + const stats = await this.mcpService.getToolStats(tenantId, startDate, endDate); + res.json({ data: stats, total: stats.length }); + } catch (error) { + next(error); + } + } + + // ============================================ + // HELPERS + // ============================================ + + private extractPermissions(req: Request): string[] { + const permHeader = req.headers['x-permissions'] as string; + if (!permHeader) return []; + + try { + return JSON.parse(permHeader); + } catch { + return permHeader.split(',').map((p) => p.trim()); + } + } +} diff --git a/src/modules/mcp/dto/index.ts b/src/modules/mcp/dto/index.ts new file mode 100644 index 0000000..06ba2ec --- /dev/null +++ b/src/modules/mcp/dto/index.ts @@ -0,0 +1 @@ +export * from './mcp.dto'; diff --git a/src/modules/mcp/dto/mcp.dto.ts b/src/modules/mcp/dto/mcp.dto.ts new file mode 100644 index 0000000..b586736 --- /dev/null +++ b/src/modules/mcp/dto/mcp.dto.ts @@ -0,0 +1,66 @@ +// ===================================================== +// DTOs: MCP Server +// Modulo: MGN-022 +// Version: 1.0.0 +// ===================================================== + +import { ToolCallStatus } from '../entities'; +import { CallerType } from '../interfaces'; + +// ============================================ +// Tool Call DTOs +// ============================================ + +export interface CallToolDto { + tool: string; + parameters?: Record; +} + +export interface ToolCallResultDto { + success: boolean; + toolName: string; + result?: any; + error?: string; + callId: string; +} + +export interface StartCallData { + tenantId: string; + toolName: string; + parameters: Record; + agentId?: string; + conversationId?: string; + callerType: CallerType; + userId?: string; +} + +// ============================================ +// History & Filters DTOs +// ============================================ + +export interface CallHistoryFilters { + toolName?: string; + status?: ToolCallStatus; + startDate?: Date; + endDate?: Date; + page: number; + limit: number; +} + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + limit: number; +} + +// ============================================ +// Resource DTOs +// ============================================ + +export interface ResourceContentDto { + uri: string; + name: string; + mimeType: string; + content: any; +} diff --git a/src/modules/mcp/entities/index.ts b/src/modules/mcp/entities/index.ts new file mode 100644 index 0000000..f9c8658 --- /dev/null +++ b/src/modules/mcp/entities/index.ts @@ -0,0 +1,2 @@ +export { ToolCall, ToolCallStatus } from './tool-call.entity'; +export { ToolCallResult, ResultType } from './tool-call-result.entity'; diff --git a/src/modules/mcp/entities/tool-call-result.entity.ts b/src/modules/mcp/entities/tool-call-result.entity.ts new file mode 100644 index 0000000..b4ab2b2 --- /dev/null +++ b/src/modules/mcp/entities/tool-call-result.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + OneToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { ToolCall } from './tool-call.entity'; + +export type ResultType = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'null' | 'error'; + +@Entity({ name: 'tool_call_results', schema: 'ai' }) +export class ToolCallResult { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tool_call_id', type: 'uuid' }) + toolCallId: string; + + @Column({ type: 'jsonb', nullable: true }) + result: any; + + @Column({ name: 'result_type', type: 'varchar', length: 20, default: 'object' }) + resultType: ResultType; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Index() + @Column({ name: 'error_code', type: 'varchar', length: 50, nullable: true }) + errorCode: string; + + @Column({ name: 'tokens_used', type: 'int', nullable: true }) + tokensUsed: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @OneToOne(() => ToolCall, (call) => call.result, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'tool_call_id' }) + toolCall: ToolCall; +} diff --git a/src/modules/mcp/entities/tool-call.entity.ts b/src/modules/mcp/entities/tool-call.entity.ts new file mode 100644 index 0000000..8aee11c --- /dev/null +++ b/src/modules/mcp/entities/tool-call.entity.ts @@ -0,0 +1,65 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + OneToOne, +} from 'typeorm'; +import { ToolCallResult } from './tool-call-result.entity'; +import { CallerType } from '../interfaces'; + +export type ToolCallStatus = 'pending' | 'running' | 'success' | 'error' | 'timeout'; + +@Entity({ name: 'tool_calls', schema: 'ai' }) +export class ToolCall { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'agent_id', type: 'uuid', nullable: true }) + agentId: string; + + @Index() + @Column({ name: 'conversation_id', type: 'uuid', nullable: true }) + conversationId: string; + + @Index() + @Column({ name: 'tool_name', type: 'varchar', length: 100 }) + toolName: string; + + @Column({ type: 'jsonb', default: {} }) + parameters: Record; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: ToolCallStatus; + + @Column({ name: 'duration_ms', type: 'int', nullable: true }) + durationMs: number; + + @Column({ name: 'started_at', type: 'timestamptz' }) + startedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Column({ name: 'called_by_user_id', type: 'uuid', nullable: true }) + calledByUserId: string; + + @Column({ name: 'caller_type', type: 'varchar', length: 20, default: 'agent' }) + callerType: CallerType; + + @Column({ name: 'caller_context', type: 'varchar', length: 100, nullable: true }) + callerContext: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @OneToOne(() => ToolCallResult, (result) => result.toolCall) + result: ToolCallResult; +} diff --git a/src/modules/mcp/index.ts b/src/modules/mcp/index.ts new file mode 100644 index 0000000..f83290f --- /dev/null +++ b/src/modules/mcp/index.ts @@ -0,0 +1,7 @@ +export { McpModule, McpModuleOptions } from './mcp.module'; +export { McpServerService, ToolRegistryService, ToolLoggerService } from './services'; +export { McpController } from './controllers'; +export { ToolCall, ToolCallResult, ToolCallStatus, ResultType } from './entities'; +export * from './interfaces'; +export * from './dto'; +export * from './tools'; diff --git a/src/modules/mcp/interfaces/index.ts b/src/modules/mcp/interfaces/index.ts new file mode 100644 index 0000000..d612fa9 --- /dev/null +++ b/src/modules/mcp/interfaces/index.ts @@ -0,0 +1,3 @@ +export * from './mcp-tool.interface'; +export * from './mcp-context.interface'; +export * from './mcp-resource.interface'; diff --git a/src/modules/mcp/interfaces/mcp-context.interface.ts b/src/modules/mcp/interfaces/mcp-context.interface.ts new file mode 100644 index 0000000..69488c4 --- /dev/null +++ b/src/modules/mcp/interfaces/mcp-context.interface.ts @@ -0,0 +1,17 @@ +// ===================================================== +// Interfaces: MCP Context +// Modulo: MGN-022 +// Version: 1.0.0 +// ===================================================== + +export type CallerType = 'agent' | 'api' | 'webhook' | 'system' | 'test'; + +export interface McpContext { + tenantId: string; + userId?: string; + agentId?: string; + conversationId?: string; + callerType: CallerType; + permissions: string[]; + metadata?: Record; +} diff --git a/src/modules/mcp/interfaces/mcp-resource.interface.ts b/src/modules/mcp/interfaces/mcp-resource.interface.ts new file mode 100644 index 0000000..e678ab3 --- /dev/null +++ b/src/modules/mcp/interfaces/mcp-resource.interface.ts @@ -0,0 +1,18 @@ +// ===================================================== +// Interfaces: MCP Resource +// Modulo: MGN-022 +// Version: 1.0.0 +// ===================================================== + +import { McpContext } from './mcp-context.interface'; + +export interface McpResource { + uri: string; + name: string; + description: string; + mimeType: string; +} + +export interface McpResourceWithHandler extends McpResource { + handler: (context: McpContext) => Promise; +} diff --git a/src/modules/mcp/interfaces/mcp-tool.interface.ts b/src/modules/mcp/interfaces/mcp-tool.interface.ts new file mode 100644 index 0000000..155f8d7 --- /dev/null +++ b/src/modules/mcp/interfaces/mcp-tool.interface.ts @@ -0,0 +1,62 @@ +// ===================================================== +// Interfaces: MCP Tool +// Modulo: MGN-022 +// Version: 1.0.0 +// ===================================================== + +import { McpContext } from './mcp-context.interface'; + +export interface JSONSchema { + type: string; + properties?: Record; + required?: string[]; + items?: JSONSchemaProperty; + description?: string; +} + +export interface JSONSchemaProperty { + type: string; + description?: string; + format?: string; + enum?: string[]; + minimum?: number; + maximum?: number; + default?: any; + items?: JSONSchemaProperty; + properties?: Record; + required?: string[]; +} + +export interface RateLimitConfig { + maxCalls: number; + windowMs: number; + perTenant?: boolean; +} + +export type ToolCategory = + | 'products' + | 'inventory' + | 'orders' + | 'customers' + | 'fiados' + | 'system'; + +export interface McpToolDefinition { + name: string; + description: string; + parameters: JSONSchema; + returns: JSONSchema; + category: ToolCategory; + permissions?: string[]; + rateLimit?: RateLimitConfig; +} + +export type McpToolHandler = ( + params: TParams, + context: McpContext +) => Promise; + +export interface McpToolProvider { + getTools(): McpToolDefinition[]; + getHandler(toolName: string): McpToolHandler | undefined; +} diff --git a/src/modules/mcp/mcp.module.ts b/src/modules/mcp/mcp.module.ts new file mode 100644 index 0000000..453911f --- /dev/null +++ b/src/modules/mcp/mcp.module.ts @@ -0,0 +1,64 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { McpServerService, ToolRegistryService, ToolLoggerService } from './services'; +import { McpController } from './controllers'; +import { ToolCall, ToolCallResult } from './entities'; +import { + ProductsToolsService, + InventoryToolsService, + OrdersToolsService, + CustomersToolsService, + FiadosToolsService, +} from './tools'; + +export interface McpModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class McpModule { + public router: Router; + public mcpService: McpServerService; + public toolRegistry: ToolRegistryService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: McpModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + // Repositories + const toolCallRepository = this.dataSource.getRepository(ToolCall); + const toolCallResultRepository = this.dataSource.getRepository(ToolCallResult); + + // Tool Logger + const toolLogger = new ToolLoggerService(toolCallRepository, toolCallResultRepository); + + // Tool Registry + this.toolRegistry = new ToolRegistryService(); + + // Register tool providers + this.toolRegistry.registerProvider(new ProductsToolsService()); + this.toolRegistry.registerProvider(new InventoryToolsService()); + this.toolRegistry.registerProvider(new OrdersToolsService()); + this.toolRegistry.registerProvider(new CustomersToolsService()); + this.toolRegistry.registerProvider(new FiadosToolsService()); + + // MCP Server Service + this.mcpService = new McpServerService(this.toolRegistry, toolLogger); + } + + private initializeRoutes(): void { + const mcpController = new McpController(this.mcpService); + this.router.use(`${this.basePath}/mcp`, mcpController.router); + } + + static getEntities(): Function[] { + return [ToolCall, ToolCallResult]; + } +} diff --git a/src/modules/mcp/services/index.ts b/src/modules/mcp/services/index.ts new file mode 100644 index 0000000..562464d --- /dev/null +++ b/src/modules/mcp/services/index.ts @@ -0,0 +1,3 @@ +export { McpServerService } from './mcp-server.service'; +export { ToolRegistryService } from './tool-registry.service'; +export { ToolLoggerService } from './tool-logger.service'; diff --git a/src/modules/mcp/services/mcp-server.service.ts b/src/modules/mcp/services/mcp-server.service.ts new file mode 100644 index 0000000..8aa66e9 --- /dev/null +++ b/src/modules/mcp/services/mcp-server.service.ts @@ -0,0 +1,197 @@ +import { ToolRegistryService } from './tool-registry.service'; +import { ToolLoggerService } from './tool-logger.service'; +import { + McpToolDefinition, + McpContext, + McpResource, + McpResourceWithHandler, +} from '../interfaces'; +import { ToolCallResultDto, CallHistoryFilters, PaginatedResult } from '../dto'; +import { ToolCall } from '../entities'; + +export class McpServerService { + private resources: Map = new Map(); + + constructor( + private readonly toolRegistry: ToolRegistryService, + private readonly toolLogger: ToolLoggerService + ) { + this.initializeResources(); + } + + // ============================================ + // TOOLS + // ============================================ + + listTools(): McpToolDefinition[] { + return this.toolRegistry.getAllTools(); + } + + getTool(name: string): McpToolDefinition | null { + return this.toolRegistry.getTool(name); + } + + async callTool( + toolName: string, + params: Record, + context: McpContext + ): Promise { + // 1. Get tool definition + const tool = this.toolRegistry.getTool(toolName); + if (!tool) { + return { + success: false, + toolName, + error: `Tool '${toolName}' not found`, + callId: '', + }; + } + + // 2. Check permissions + if (tool.permissions && tool.permissions.length > 0) { + const hasPermission = tool.permissions.some((p) => + context.permissions.includes(p) + ); + if (!hasPermission) { + return { + success: false, + toolName, + error: `Missing permissions for tool '${toolName}'`, + callId: '', + }; + } + } + + // 3. Start logging + const callId = await this.toolLogger.startCall({ + tenantId: context.tenantId, + toolName, + parameters: params, + agentId: context.agentId, + conversationId: context.conversationId, + callerType: context.callerType, + userId: context.userId, + }); + + try { + // 4. Get and execute handler + const handler = this.toolRegistry.getHandler(toolName); + if (!handler) { + await this.toolLogger.failCall(callId, 'Handler not found', 'HANDLER_NOT_FOUND'); + return { + success: false, + toolName, + error: `Handler for tool '${toolName}' not found`, + callId, + }; + } + + const result = await handler(params, context); + + // 5. Log success + await this.toolLogger.completeCall(callId, result); + + return { + success: true, + toolName, + result, + callId, + }; + } catch (error: any) { + // 6. Log error + await this.toolLogger.failCall( + callId, + error.message || 'Execution error', + error.code || 'EXECUTION_ERROR' + ); + + return { + success: false, + toolName, + error: error.message || 'Tool execution failed', + callId, + }; + } + } + + // ============================================ + // RESOURCES + // ============================================ + + listResources(): McpResource[] { + return Array.from(this.resources.values()).map(({ handler, ...resource }) => resource); + } + + async getResource(uri: string, context: McpContext): Promise { + const resource = this.resources.get(uri); + if (!resource) { + throw new Error(`Resource '${uri}' not found`); + } + + return resource.handler(context); + } + + private initializeResources(): void { + // Business config resource + this.resources.set('erp://config/business', { + uri: 'erp://config/business', + name: 'Business Configuration', + description: 'Basic business information and settings', + mimeType: 'application/json', + handler: async (context) => ({ + tenantId: context.tenantId, + message: 'Business configuration - connect to tenant config service', + // TODO: Connect to actual tenant config service + }), + }); + + // Categories catalog resource + this.resources.set('erp://catalog/categories', { + uri: 'erp://catalog/categories', + name: 'Product Categories', + description: 'List of product categories', + mimeType: 'application/json', + handler: async (context) => ({ + tenantId: context.tenantId, + categories: [], + message: 'Categories catalog - connect to products service', + // TODO: Connect to actual products service + }), + }); + + // Inventory summary resource + this.resources.set('erp://inventory/summary', { + uri: 'erp://inventory/summary', + name: 'Inventory Summary', + description: 'Summary of current inventory status', + mimeType: 'application/json', + handler: async (context) => ({ + tenantId: context.tenantId, + totalProducts: 0, + totalValue: 0, + lowStockCount: 0, + message: 'Inventory summary - connect to inventory service', + // TODO: Connect to actual inventory service + }), + }); + } + + // ============================================ + // HISTORY / AUDIT + // ============================================ + + async getCallHistory( + tenantId: string, + filters: CallHistoryFilters + ): Promise> { + return this.toolLogger.getCallHistory(tenantId, filters); + } + + async getCallDetails(id: string, tenantId: string): Promise { + return this.toolLogger.getCallById(id, tenantId); + } + + async getToolStats(tenantId: string, startDate: Date, endDate: Date) { + return this.toolLogger.getToolStats(tenantId, startDate, endDate); + } +} diff --git a/src/modules/mcp/services/tool-logger.service.ts b/src/modules/mcp/services/tool-logger.service.ts new file mode 100644 index 0000000..797ba79 --- /dev/null +++ b/src/modules/mcp/services/tool-logger.service.ts @@ -0,0 +1,171 @@ +import { Repository } from 'typeorm'; +import { ToolCall, ToolCallResult, ResultType } from '../entities'; +import { StartCallData, CallHistoryFilters, PaginatedResult } from '../dto'; + +export class ToolLoggerService { + constructor( + private readonly toolCallRepo: Repository, + private readonly resultRepo: Repository + ) {} + + async startCall(data: StartCallData): Promise { + const call = this.toolCallRepo.create({ + tenantId: data.tenantId, + toolName: data.toolName, + parameters: data.parameters, + agentId: data.agentId, + conversationId: data.conversationId, + callerType: data.callerType, + calledByUserId: data.userId, + status: 'running', + startedAt: new Date(), + }); + + const saved = await this.toolCallRepo.save(call); + return saved.id; + } + + async completeCall(callId: string, result: any): Promise { + const call = await this.toolCallRepo.findOne({ where: { id: callId } }); + if (!call) return; + + const duration = Date.now() - call.startedAt.getTime(); + + await this.toolCallRepo.update(callId, { + status: 'success', + completedAt: new Date(), + durationMs: duration, + }); + + await this.resultRepo.save({ + toolCallId: callId, + result, + resultType: this.getResultType(result), + }); + } + + async failCall(callId: string, errorMessage: string, errorCode: string): Promise { + const call = await this.toolCallRepo.findOne({ where: { id: callId } }); + if (!call) return; + + const duration = Date.now() - call.startedAt.getTime(); + + await this.toolCallRepo.update(callId, { + status: 'error', + completedAt: new Date(), + durationMs: duration, + }); + + await this.resultRepo.save({ + toolCallId: callId, + resultType: 'error', + errorMessage, + errorCode, + }); + } + + async timeoutCall(callId: string): Promise { + const call = await this.toolCallRepo.findOne({ where: { id: callId } }); + if (!call) return; + + const duration = Date.now() - call.startedAt.getTime(); + + await this.toolCallRepo.update(callId, { + status: 'timeout', + completedAt: new Date(), + durationMs: duration, + }); + + await this.resultRepo.save({ + toolCallId: callId, + resultType: 'error', + errorMessage: 'Tool execution timed out', + errorCode: 'TIMEOUT', + }); + } + + async getCallHistory( + tenantId: string, + filters: CallHistoryFilters + ): Promise> { + const qb = this.toolCallRepo + .createQueryBuilder('tc') + .leftJoinAndSelect('tc.result', 'result') + .where('tc.tenant_id = :tenantId', { tenantId }); + + if (filters.toolName) { + qb.andWhere('tc.tool_name = :toolName', { toolName: filters.toolName }); + } + + if (filters.status) { + qb.andWhere('tc.status = :status', { status: filters.status }); + } + + if (filters.startDate) { + qb.andWhere('tc.created_at >= :startDate', { startDate: filters.startDate }); + } + + if (filters.endDate) { + qb.andWhere('tc.created_at <= :endDate', { endDate: filters.endDate }); + } + + qb.orderBy('tc.created_at', 'DESC'); + qb.skip((filters.page - 1) * filters.limit); + qb.take(filters.limit); + + const [data, total] = await qb.getManyAndCount(); + + return { data, total, page: filters.page, limit: filters.limit }; + } + + async getCallById(id: string, tenantId: string): Promise { + return this.toolCallRepo.findOne({ + where: { id, tenantId }, + relations: ['result'], + }); + } + + async getToolStats( + tenantId: string, + startDate: Date, + endDate: Date + ): Promise<{ + toolName: string; + totalCalls: number; + successfulCalls: number; + failedCalls: number; + avgDurationMs: number; + }[]> { + const result = await this.toolCallRepo + .createQueryBuilder('tc') + .select('tc.tool_name', 'toolName') + .addSelect('COUNT(*)', 'totalCalls') + .addSelect("COUNT(*) FILTER (WHERE tc.status = 'success')", 'successfulCalls') + .addSelect("COUNT(*) FILTER (WHERE tc.status = 'error')", 'failedCalls') + .addSelect('AVG(tc.duration_ms)', 'avgDurationMs') + .where('tc.tenant_id = :tenantId', { tenantId }) + .andWhere('tc.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .groupBy('tc.tool_name') + .orderBy('totalCalls', 'DESC') + .getRawMany(); + + return result.map((r) => ({ + toolName: r.toolName, + totalCalls: parseInt(r.totalCalls) || 0, + successfulCalls: parseInt(r.successfulCalls) || 0, + failedCalls: parseInt(r.failedCalls) || 0, + avgDurationMs: parseFloat(r.avgDurationMs) || 0, + })); + } + + private getResultType(result: any): ResultType { + if (result === null) return 'null'; + if (Array.isArray(result)) return 'array'; + const type = typeof result; + if (type === 'object') return 'object'; + if (type === 'string') return 'string'; + if (type === 'number') return 'number'; + if (type === 'boolean') return 'boolean'; + return 'object'; + } +} diff --git a/src/modules/mcp/services/tool-registry.service.ts b/src/modules/mcp/services/tool-registry.service.ts new file mode 100644 index 0000000..8661f3b --- /dev/null +++ b/src/modules/mcp/services/tool-registry.service.ts @@ -0,0 +1,53 @@ +import { + McpToolDefinition, + McpToolHandler, + McpToolProvider, + ToolCategory, +} from '../interfaces'; + +export class ToolRegistryService { + private tools: Map = new Map(); + private handlers: Map = new Map(); + private providers: McpToolProvider[] = []; + + registerProvider(provider: McpToolProvider): void { + this.providers.push(provider); + const tools = provider.getTools(); + + for (const tool of tools) { + this.tools.set(tool.name, tool); + const handler = provider.getHandler(tool.name); + if (handler) { + this.handlers.set(tool.name, handler); + } + } + } + + getAllTools(): McpToolDefinition[] { + return Array.from(this.tools.values()); + } + + getTool(name: string): McpToolDefinition | null { + return this.tools.get(name) || null; + } + + getHandler(name: string): McpToolHandler | null { + return this.handlers.get(name) || null; + } + + getToolsByCategory(category: ToolCategory): McpToolDefinition[] { + return Array.from(this.tools.values()).filter((t) => t.category === category); + } + + hasTool(name: string): boolean { + return this.tools.has(name); + } + + getCategories(): ToolCategory[] { + const categories = new Set(); + for (const tool of this.tools.values()) { + categories.add(tool.category); + } + return Array.from(categories); + } +} diff --git a/src/modules/mcp/tools/customers-tools.service.ts b/src/modules/mcp/tools/customers-tools.service.ts new file mode 100644 index 0000000..daa5298 --- /dev/null +++ b/src/modules/mcp/tools/customers-tools.service.ts @@ -0,0 +1,94 @@ +import { + McpToolProvider, + McpToolDefinition, + McpToolHandler, + McpContext, +} from '../interfaces'; + +/** + * Customers Tools Service + * Provides MCP tools for customer management. + * + * TODO: Connect to actual CustomersService when available. + */ +export class CustomersToolsService implements McpToolProvider { + getTools(): McpToolDefinition[] { + return [ + { + name: 'search_customers', + description: 'Busca clientes por nombre, telefono o email', + category: 'customers', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Texto de busqueda' }, + limit: { type: 'number', description: 'Limite de resultados', default: 10 }, + }, + required: ['query'], + }, + returns: { type: 'array' }, + }, + { + name: 'get_customer_balance', + description: 'Obtiene el saldo actual de un cliente', + category: 'customers', + parameters: { + type: 'object', + properties: { + customer_id: { type: 'string', format: 'uuid' }, + }, + required: ['customer_id'], + }, + returns: { + type: 'object', + properties: { + balance: { type: 'number' }, + credit_limit: { type: 'number' }, + }, + }, + }, + ]; + } + + getHandler(toolName: string): McpToolHandler | undefined { + const handlers: Record = { + search_customers: this.searchCustomers.bind(this), + get_customer_balance: this.getCustomerBalance.bind(this), + }; + return handlers[toolName]; + } + + private async searchCustomers( + params: { query: string; limit?: number }, + context: McpContext + ): Promise { + // TODO: Connect to actual customers service + return [ + { + id: 'customer-1', + name: 'Juan Perez', + phone: '+52 55 1234 5678', + email: 'juan@example.com', + balance: 500.00, + credit_limit: 5000.00, + message: 'Conectar a CustomersService real', + }, + ]; + } + + private async getCustomerBalance( + params: { customer_id: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual customers service + return { + customer_id: params.customer_id, + customer_name: 'Cliente ejemplo', + balance: 500.00, + credit_limit: 5000.00, + available_credit: 4500.00, + last_purchase: new Date().toISOString(), + message: 'Conectar a CustomersService real', + }; + } +} diff --git a/src/modules/mcp/tools/fiados-tools.service.ts b/src/modules/mcp/tools/fiados-tools.service.ts new file mode 100644 index 0000000..6e34982 --- /dev/null +++ b/src/modules/mcp/tools/fiados-tools.service.ts @@ -0,0 +1,216 @@ +import { + McpToolProvider, + McpToolDefinition, + McpToolHandler, + McpContext, +} from '../interfaces'; + +/** + * Fiados (Credit) Tools Service + * Provides MCP tools for credit/fiado management. + * + * TODO: Connect to actual FiadosService when available. + */ +export class FiadosToolsService implements McpToolProvider { + getTools(): McpToolDefinition[] { + return [ + { + name: 'get_fiado_balance', + description: 'Consulta el saldo de credito de un cliente', + category: 'fiados', + parameters: { + type: 'object', + properties: { + customer_id: { type: 'string', format: 'uuid' }, + }, + required: ['customer_id'], + }, + returns: { type: 'object' }, + }, + { + name: 'create_fiado', + description: 'Registra una venta a credito (fiado)', + category: 'fiados', + permissions: ['fiados.create'], + parameters: { + type: 'object', + properties: { + customer_id: { type: 'string', format: 'uuid' }, + amount: { type: 'number', minimum: 0.01 }, + order_id: { type: 'string', format: 'uuid' }, + description: { type: 'string' }, + }, + required: ['customer_id', 'amount'], + }, + returns: { type: 'object' }, + }, + { + name: 'register_fiado_payment', + description: 'Registra un abono a la cuenta de credito', + category: 'fiados', + permissions: ['fiados.payment'], + parameters: { + type: 'object', + properties: { + customer_id: { type: 'string', format: 'uuid' }, + amount: { type: 'number', minimum: 0.01 }, + payment_method: { type: 'string', enum: ['cash', 'card', 'transfer'] }, + }, + required: ['customer_id', 'amount'], + }, + returns: { type: 'object' }, + }, + { + name: 'check_fiado_eligibility', + description: 'Verifica si un cliente puede comprar a credito', + category: 'fiados', + parameters: { + type: 'object', + properties: { + customer_id: { type: 'string', format: 'uuid' }, + amount: { type: 'number', minimum: 0.01 }, + }, + required: ['customer_id', 'amount'], + }, + returns: { + type: 'object', + properties: { + eligible: { type: 'boolean' }, + reason: { type: 'string' }, + }, + }, + }, + ]; + } + + getHandler(toolName: string): McpToolHandler | undefined { + const handlers: Record = { + get_fiado_balance: this.getFiadoBalance.bind(this), + create_fiado: this.createFiado.bind(this), + register_fiado_payment: this.registerFiadoPayment.bind(this), + check_fiado_eligibility: this.checkFiadoEligibility.bind(this), + }; + return handlers[toolName]; + } + + private async getFiadoBalance( + params: { customer_id: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual fiados service + return { + customer_id: params.customer_id, + customer_name: 'Cliente ejemplo', + balance: 1500.00, + credit_limit: 5000.00, + available_credit: 3500.00, + pending_fiados: [ + { id: 'fiado-1', amount: 500.00, date: '2026-01-10', status: 'pending' }, + { id: 'fiado-2', amount: 1000.00, date: '2026-01-05', status: 'pending' }, + ], + recent_payments: [], + message: 'Conectar a FiadosService real', + }; + } + + private async createFiado( + params: { customer_id: string; amount: number; order_id?: string; description?: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual fiados service + // First check eligibility + const eligibility = await this.checkFiadoEligibility( + { customer_id: params.customer_id, amount: params.amount }, + context + ); + + if (!eligibility.eligible) { + throw new Error(eligibility.reason); + } + + return { + fiado_id: 'fiado-' + Date.now(), + customer_id: params.customer_id, + amount: params.amount, + order_id: params.order_id, + description: params.description, + due_date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days + new_balance: 1500.00 + params.amount, + remaining_credit: 3500.00 - params.amount, + created_by: context.userId, + created_at: new Date().toISOString(), + message: 'Conectar a FiadosService real', + }; + } + + private async registerFiadoPayment( + params: { customer_id: string; amount: number; payment_method?: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual fiados service + return { + payment_id: 'payment-' + Date.now(), + customer_id: params.customer_id, + amount: params.amount, + payment_method: params.payment_method || 'cash', + previous_balance: 1500.00, + new_balance: 1500.00 - params.amount, + fiados_paid: [], + created_by: context.userId, + created_at: new Date().toISOString(), + message: 'Conectar a FiadosService real', + }; + } + + private async checkFiadoEligibility( + params: { customer_id: string; amount: number }, + context: McpContext + ): Promise { + // TODO: Connect to actual fiados service + const mockBalance = 1500.00; + const mockCreditLimit = 5000.00; + const mockAvailableCredit = mockCreditLimit - mockBalance; + const hasOverdue = false; + + if (hasOverdue) { + return { + eligible: false, + reason: 'Cliente tiene saldo vencido', + current_balance: mockBalance, + credit_limit: mockCreditLimit, + available_credit: mockAvailableCredit, + requested_amount: params.amount, + has_overdue: true, + suggestions: ['Solicitar pago del saldo vencido antes de continuar'], + }; + } + + if (params.amount > mockAvailableCredit) { + return { + eligible: false, + reason: 'Monto excede credito disponible', + current_balance: mockBalance, + credit_limit: mockCreditLimit, + available_credit: mockAvailableCredit, + requested_amount: params.amount, + has_overdue: false, + suggestions: [ + `Reducir el monto a $${mockAvailableCredit.toFixed(2)}`, + 'Solicitar aumento de limite de credito', + ], + }; + } + + return { + eligible: true, + reason: 'Cliente con credito disponible', + current_balance: mockBalance, + credit_limit: mockCreditLimit, + available_credit: mockAvailableCredit, + requested_amount: params.amount, + has_overdue: false, + suggestions: [], + message: 'Conectar a FiadosService real', + }; + } +} diff --git a/src/modules/mcp/tools/index.ts b/src/modules/mcp/tools/index.ts new file mode 100644 index 0000000..795912b --- /dev/null +++ b/src/modules/mcp/tools/index.ts @@ -0,0 +1,5 @@ +export { ProductsToolsService } from './products-tools.service'; +export { InventoryToolsService } from './inventory-tools.service'; +export { OrdersToolsService } from './orders-tools.service'; +export { CustomersToolsService } from './customers-tools.service'; +export { FiadosToolsService } from './fiados-tools.service'; diff --git a/src/modules/mcp/tools/inventory-tools.service.ts b/src/modules/mcp/tools/inventory-tools.service.ts new file mode 100644 index 0000000..76a45ca --- /dev/null +++ b/src/modules/mcp/tools/inventory-tools.service.ts @@ -0,0 +1,154 @@ +import { + McpToolProvider, + McpToolDefinition, + McpToolHandler, + McpContext, +} from '../interfaces'; + +/** + * Inventory Tools Service + * Provides MCP tools for inventory management. + * + * TODO: Connect to actual InventoryService when available. + */ +export class InventoryToolsService implements McpToolProvider { + getTools(): McpToolDefinition[] { + return [ + { + name: 'check_stock', + description: 'Consulta el stock actual de productos', + category: 'inventory', + parameters: { + type: 'object', + properties: { + product_ids: { type: 'array', description: 'IDs de productos a consultar' }, + warehouse_id: { type: 'string', description: 'ID del almacen' }, + }, + }, + returns: { type: 'array' }, + }, + { + name: 'get_low_stock_products', + description: 'Lista productos que estan por debajo del minimo de stock', + category: 'inventory', + parameters: { + type: 'object', + properties: { + threshold: { type: 'number', description: 'Umbral de stock bajo' }, + }, + }, + returns: { type: 'array' }, + }, + { + name: 'record_inventory_movement', + description: 'Registra un movimiento de inventario (entrada, salida, ajuste)', + category: 'inventory', + permissions: ['inventory.write'], + parameters: { + type: 'object', + properties: { + product_id: { type: 'string', format: 'uuid' }, + quantity: { type: 'number' }, + movement_type: { type: 'string', enum: ['in', 'out', 'adjustment'] }, + reason: { type: 'string' }, + }, + required: ['product_id', 'quantity', 'movement_type'], + }, + returns: { type: 'object' }, + }, + { + name: 'get_inventory_value', + description: 'Calcula el valor total del inventario', + category: 'inventory', + parameters: { + type: 'object', + properties: { + warehouse_id: { type: 'string', description: 'ID del almacen (opcional)' }, + }, + }, + returns: { + type: 'object', + properties: { + total_value: { type: 'number' }, + items_count: { type: 'number' }, + }, + }, + }, + ]; + } + + getHandler(toolName: string): McpToolHandler | undefined { + const handlers: Record = { + check_stock: this.checkStock.bind(this), + get_low_stock_products: this.getLowStockProducts.bind(this), + record_inventory_movement: this.recordInventoryMovement.bind(this), + get_inventory_value: this.getInventoryValue.bind(this), + }; + return handlers[toolName]; + } + + private async checkStock( + params: { product_ids?: string[]; warehouse_id?: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual inventory service + return [ + { + product_id: 'sample-1', + product_name: 'Producto ejemplo', + stock: 100, + warehouse_id: params.warehouse_id || 'default', + message: 'Conectar a InventoryService real', + }, + ]; + } + + private async getLowStockProducts( + params: { threshold?: number }, + context: McpContext + ): Promise { + // TODO: Connect to actual inventory service + const threshold = params.threshold || 10; + return [ + { + product_id: 'low-stock-1', + product_name: 'Producto bajo stock', + current_stock: 5, + min_stock: threshold, + shortage: threshold - 5, + message: 'Conectar a InventoryService real', + }, + ]; + } + + private async recordInventoryMovement( + params: { product_id: string; quantity: number; movement_type: string; reason?: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual inventory service + return { + movement_id: 'mov-' + Date.now(), + product_id: params.product_id, + quantity: params.quantity, + movement_type: params.movement_type, + reason: params.reason, + recorded_by: context.userId, + recorded_at: new Date().toISOString(), + message: 'Conectar a InventoryService real', + }; + } + + private async getInventoryValue( + params: { warehouse_id?: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual inventory service + return { + total_value: 150000.00, + items_count: 500, + warehouse_id: params.warehouse_id || 'all', + currency: 'MXN', + message: 'Conectar a InventoryService real', + }; + } +} diff --git a/src/modules/mcp/tools/orders-tools.service.ts b/src/modules/mcp/tools/orders-tools.service.ts new file mode 100644 index 0000000..facc0b0 --- /dev/null +++ b/src/modules/mcp/tools/orders-tools.service.ts @@ -0,0 +1,139 @@ +import { + McpToolProvider, + McpToolDefinition, + McpToolHandler, + McpContext, +} from '../interfaces'; + +/** + * Orders Tools Service + * Provides MCP tools for order management. + * + * TODO: Connect to actual OrdersService when available. + */ +export class OrdersToolsService implements McpToolProvider { + getTools(): McpToolDefinition[] { + return [ + { + name: 'create_order', + description: 'Crea un nuevo pedido', + category: 'orders', + permissions: ['orders.create'], + parameters: { + type: 'object', + properties: { + customer_id: { type: 'string', format: 'uuid', description: 'ID del cliente' }, + items: { + type: 'array', + description: 'Items del pedido', + items: { + type: 'object', + properties: { + product_id: { type: 'string' }, + quantity: { type: 'number' }, + unit_price: { type: 'number' }, + }, + }, + }, + payment_method: { type: 'string', enum: ['cash', 'card', 'transfer', 'fiado'] }, + notes: { type: 'string' }, + }, + required: ['customer_id', 'items'], + }, + returns: { type: 'object' }, + }, + { + name: 'get_order_status', + description: 'Consulta el estado de un pedido', + category: 'orders', + parameters: { + type: 'object', + properties: { + order_id: { type: 'string', format: 'uuid' }, + }, + required: ['order_id'], + }, + returns: { type: 'object' }, + }, + { + name: 'update_order_status', + description: 'Actualiza el estado de un pedido', + category: 'orders', + permissions: ['orders.update'], + parameters: { + type: 'object', + properties: { + order_id: { type: 'string', format: 'uuid' }, + status: { + type: 'string', + enum: ['pending', 'confirmed', 'preparing', 'ready', 'delivered', 'cancelled'], + }, + }, + required: ['order_id', 'status'], + }, + returns: { type: 'object' }, + }, + ]; + } + + getHandler(toolName: string): McpToolHandler | undefined { + const handlers: Record = { + create_order: this.createOrder.bind(this), + get_order_status: this.getOrderStatus.bind(this), + update_order_status: this.updateOrderStatus.bind(this), + }; + return handlers[toolName]; + } + + private async createOrder( + params: { customer_id: string; items: any[]; payment_method?: string; notes?: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual orders service + const subtotal = params.items.reduce((sum, item) => sum + (item.quantity * (item.unit_price || 0)), 0); + return { + order_id: 'order-' + Date.now(), + customer_id: params.customer_id, + items: params.items, + subtotal, + tax: subtotal * 0.16, + total: subtotal * 1.16, + payment_method: params.payment_method || 'cash', + status: 'pending', + created_by: context.userId, + created_at: new Date().toISOString(), + message: 'Conectar a OrdersService real', + }; + } + + private async getOrderStatus( + params: { order_id: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual orders service + return { + order_id: params.order_id, + status: 'pending', + customer_name: 'Cliente ejemplo', + total: 1160.00, + items_count: 3, + created_at: new Date().toISOString(), + message: 'Conectar a OrdersService real', + }; + } + + private async updateOrderStatus( + params: { order_id: string; status: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual orders service + return { + order_id: params.order_id, + previous_status: 'pending', + new_status: params.status, + updated_by: context.userId, + updated_at: new Date().toISOString(), + message: 'Conectar a OrdersService real', + }; + } +} diff --git a/src/modules/mcp/tools/products-tools.service.ts b/src/modules/mcp/tools/products-tools.service.ts new file mode 100644 index 0000000..92c3e44 --- /dev/null +++ b/src/modules/mcp/tools/products-tools.service.ts @@ -0,0 +1,128 @@ +import { + McpToolProvider, + McpToolDefinition, + McpToolHandler, + McpContext, +} from '../interfaces'; + +/** + * Products Tools Service + * Provides MCP tools for product management. + * + * TODO: Connect to actual ProductsService when available. + */ +export class ProductsToolsService implements McpToolProvider { + getTools(): McpToolDefinition[] { + return [ + { + name: 'list_products', + description: 'Lista productos filtrados por categoria, nombre o precio', + category: 'products', + parameters: { + type: 'object', + properties: { + category: { type: 'string', description: 'Filtrar por categoria' }, + search: { type: 'string', description: 'Buscar por nombre' }, + min_price: { type: 'number', description: 'Precio minimo' }, + max_price: { type: 'number', description: 'Precio maximo' }, + limit: { type: 'number', description: 'Limite de resultados', default: 20 }, + }, + }, + returns: { + type: 'array', + items: { type: 'object' }, + }, + }, + { + name: 'get_product_details', + description: 'Obtiene detalles completos de un producto', + category: 'products', + parameters: { + type: 'object', + properties: { + product_id: { type: 'string', format: 'uuid', description: 'ID del producto' }, + }, + required: ['product_id'], + }, + returns: { type: 'object' }, + }, + { + name: 'check_product_availability', + description: 'Verifica si hay stock suficiente de un producto', + category: 'products', + parameters: { + type: 'object', + properties: { + product_id: { type: 'string', format: 'uuid', description: 'ID del producto' }, + quantity: { type: 'number', minimum: 1, description: 'Cantidad requerida' }, + }, + required: ['product_id', 'quantity'], + }, + returns: { + type: 'object', + properties: { + available: { type: 'boolean' }, + current_stock: { type: 'number' }, + }, + }, + }, + ]; + } + + getHandler(toolName: string): McpToolHandler | undefined { + const handlers: Record = { + list_products: this.listProducts.bind(this), + get_product_details: this.getProductDetails.bind(this), + check_product_availability: this.checkProductAvailability.bind(this), + }; + return handlers[toolName]; + } + + private async listProducts( + params: { category?: string; search?: string; min_price?: number; max_price?: number; limit?: number }, + context: McpContext + ): Promise { + // TODO: Connect to actual products service + return [ + { + id: 'sample-product-1', + name: 'Producto de ejemplo 1', + price: 99.99, + stock: 50, + category: params.category || 'general', + message: 'Conectar a ProductsService real', + }, + ]; + } + + private async getProductDetails( + params: { product_id: string }, + context: McpContext + ): Promise { + // TODO: Connect to actual products service + return { + id: params.product_id, + name: 'Producto de ejemplo', + description: 'Descripcion del producto', + sku: 'SKU-001', + price: 99.99, + stock: 50, + message: 'Conectar a ProductsService real', + }; + } + + private async checkProductAvailability( + params: { product_id: string; quantity: number }, + context: McpContext + ): Promise { + // TODO: Connect to actual inventory service + const mockStock = 50; + return { + available: mockStock >= params.quantity, + current_stock: mockStock, + requested_quantity: params.quantity, + shortage: Math.max(0, params.quantity - mockStock), + message: 'Conectar a InventoryService real', + }; + } +} diff --git a/src/modules/mobile/entities/index.ts b/src/modules/mobile/entities/index.ts new file mode 100644 index 0000000..a109c57 --- /dev/null +++ b/src/modules/mobile/entities/index.ts @@ -0,0 +1,6 @@ +export { MobileSession, MobileSessionStatus } from './mobile-session.entity'; +export { OfflineSyncQueue, SyncOperation, SyncStatus, ConflictResolution } from './offline-sync-queue.entity'; +export { SyncConflict, ConflictType, ConflictResolutionType } from './sync-conflict.entity'; +export { PushToken, PushProvider } from './push-token.entity'; +export { PushNotificationLog, NotificationStatus, NotificationCategory } from './push-notification-log.entity'; +export { PaymentTransaction, PaymentSourceType, PaymentMethod, PaymentStatus, CardType } from './payment-transaction.entity'; diff --git a/src/modules/mobile/entities/mobile-session.entity.ts b/src/modules/mobile/entities/mobile-session.entity.ts new file mode 100644 index 0000000..1fa12be --- /dev/null +++ b/src/modules/mobile/entities/mobile-session.entity.ts @@ -0,0 +1,97 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type MobileSessionStatus = 'active' | 'paused' | 'expired' | 'terminated'; + +@Entity({ name: 'mobile_sessions', schema: 'mobile' }) +export class MobileSession { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + // Estado de la sesion + @Index() + @Column({ type: 'varchar', length: 20, default: 'active' }) + status: MobileSessionStatus; + + // Perfil activo + @Column({ name: 'active_profile_id', type: 'uuid', nullable: true }) + activeProfileId: string; + + @Column({ name: 'active_profile_code', type: 'varchar', length: 10, nullable: true }) + activeProfileCode: string; + + // Modo de operacion + @Column({ name: 'is_offline_mode', type: 'boolean', default: false }) + isOfflineMode: boolean; + + @Column({ name: 'offline_since', type: 'timestamptz', nullable: true }) + offlineSince: Date; + + // Sincronizacion + @Column({ name: 'last_sync_at', type: 'timestamptz', nullable: true }) + lastSyncAt: Date; + + @Column({ name: 'pending_sync_count', type: 'integer', default: 0 }) + pendingSyncCount: number; + + // Ubicacion + @Column({ name: 'last_latitude', type: 'decimal', precision: 10, scale: 8, nullable: true }) + lastLatitude: number; + + @Column({ name: 'last_longitude', type: 'decimal', precision: 11, scale: 8, nullable: true }) + lastLongitude: number; + + @Column({ name: 'last_location_at', type: 'timestamptz', nullable: true }) + lastLocationAt: Date; + + // Metadata + @Column({ name: 'app_version', type: 'varchar', length: 20, nullable: true }) + appVersion: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + platform: string; // ios, android + + @Column({ name: 'os_version', type: 'varchar', length: 20, nullable: true }) + osVersion: string; + + // Tiempos + @Column({ name: 'started_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + startedAt: Date; + + @Column({ name: 'last_activity_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + lastActivityAt: Date; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @Column({ name: 'ended_at', type: 'timestamptz', nullable: true }) + endedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/mobile/entities/offline-sync-queue.entity.ts b/src/modules/mobile/entities/offline-sync-queue.entity.ts new file mode 100644 index 0000000..90bdf13 --- /dev/null +++ b/src/modules/mobile/entities/offline-sync-queue.entity.ts @@ -0,0 +1,93 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type SyncOperation = 'create' | 'update' | 'delete'; +export type SyncStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'conflict'; +export type ConflictResolution = 'local_wins' | 'server_wins' | 'merged' | 'manual'; + +@Entity({ name: 'offline_sync_queue', schema: 'mobile' }) +export class OfflineSyncQueue { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'session_id', type: 'uuid', nullable: true }) + sessionId: string; + + // Operacion + @Column({ name: 'entity_type', type: 'varchar', length: 50 }) + entityType: string; // sale, attendance, inventory_count, etc. + + @Column({ name: 'entity_id', type: 'uuid', nullable: true }) + entityId: string; + + @Column({ type: 'varchar', length: 20 }) + operation: SyncOperation; + + // Datos + @Column({ type: 'jsonb' }) + payload: Record; + + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + // Orden y dependencias + @Index() + @Column({ name: 'sequence_number', type: 'bigint' }) + sequenceNumber: number; + + @Column({ name: 'depends_on', type: 'uuid', nullable: true }) + dependsOn: string; + + // Estado + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: SyncStatus; + + // Procesamiento + @Column({ name: 'retry_count', type: 'integer', default: 0 }) + retryCount: number; + + @Column({ name: 'max_retries', type: 'integer', default: 3 }) + maxRetries: number; + + @Column({ name: 'last_error', type: 'text', nullable: true }) + lastError: string; + + @Column({ name: 'processed_at', type: 'timestamptz', nullable: true }) + processedAt: Date; + + // Conflicto + @Column({ name: 'conflict_data', type: 'jsonb', nullable: true }) + conflictData: Record; + + @Column({ name: 'conflict_resolved_at', type: 'timestamptz', nullable: true }) + conflictResolvedAt: Date; + + @Column({ name: 'conflict_resolution', type: 'varchar', length: 20, nullable: true }) + conflictResolution: ConflictResolution; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/mobile/entities/payment-transaction.entity.ts b/src/modules/mobile/entities/payment-transaction.entity.ts new file mode 100644 index 0000000..b9da1a4 --- /dev/null +++ b/src/modules/mobile/entities/payment-transaction.entity.ts @@ -0,0 +1,115 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type PaymentSourceType = 'sale' | 'invoice' | 'subscription'; +export type PaymentMethod = 'card' | 'contactless' | 'qr' | 'link'; +export type PaymentStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'refunded' | 'cancelled'; +export type CardType = 'credit' | 'debit'; + +@Entity({ name: 'payment_transactions', schema: 'mobile' }) +export class PaymentTransaction { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'device_id', type: 'uuid', nullable: true }) + deviceId: string; + + // Referencia al documento origen + @Index() + @Column({ name: 'source_type', type: 'varchar', length: 30 }) + sourceType: PaymentSourceType; + + @Column({ name: 'source_id', type: 'uuid' }) + sourceId: string; + + // Terminal de pago + @Column({ name: 'terminal_provider', type: 'varchar', length: 30 }) + terminalProvider: string; // clip, mercadopago, stripe + + @Column({ name: 'terminal_id', type: 'varchar', length: 100, nullable: true }) + terminalId: string; + + // Transaccion + @Index() + @Column({ name: 'external_transaction_id', type: 'varchar', length: 255, nullable: true }) + externalTransactionId: string; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + amount: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'tip_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + tipAmount: number; + + @Column({ name: 'total_amount', type: 'decimal', precision: 12, scale: 2 }) + totalAmount: number; + + // Metodo de pago + @Column({ name: 'payment_method', type: 'varchar', length: 30 }) + paymentMethod: PaymentMethod; + + @Column({ name: 'card_brand', type: 'varchar', length: 20, nullable: true }) + cardBrand: string; // visa, mastercard, amex + + @Column({ name: 'card_last_four', type: 'varchar', length: 4, nullable: true }) + cardLastFour: string; + + @Column({ name: 'card_type', type: 'varchar', length: 20, nullable: true }) + cardType: CardType; + + // Estado + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: PaymentStatus; + + @Column({ name: 'failure_reason', type: 'text', nullable: true }) + failureReason: string; + + // Tiempos + @Column({ name: 'initiated_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + initiatedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + // Metadata del proveedor + @Column({ name: 'provider_response', type: 'jsonb', default: {} }) + providerResponse: Record; + + // Recibo + @Column({ name: 'receipt_url', type: 'text', nullable: true }) + receiptUrl: string; + + @Column({ name: 'receipt_sent', type: 'boolean', default: false }) + receiptSent: boolean; + + @Column({ name: 'receipt_sent_to', type: 'varchar', length: 255, nullable: true }) + receiptSentTo: string; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/mobile/entities/push-notification-log.entity.ts b/src/modules/mobile/entities/push-notification-log.entity.ts new file mode 100644 index 0000000..195bf7c --- /dev/null +++ b/src/modules/mobile/entities/push-notification-log.entity.ts @@ -0,0 +1,81 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { PushToken } from './push-token.entity'; + +export type NotificationStatus = 'sent' | 'delivered' | 'failed' | 'read'; +export type NotificationCategory = 'attendance' | 'sale' | 'inventory' | 'alert' | 'system'; + +/** + * Log de notificaciones push enviadas. + * Mapea a mobile.push_notifications_log (DDL: 04-mobile.sql) + */ +@Entity({ name: 'push_notifications_log', schema: 'mobile' }) +export class PushNotificationLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Destino + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid', nullable: true }) + deviceId: string; + + @Column({ name: 'push_token_id', type: 'uuid', nullable: true }) + pushTokenId: string; + + @ManyToOne(() => PushToken) + @JoinColumn({ name: 'push_token_id' }) + pushToken: PushToken; + + // Notificación + @Column({ type: 'varchar', length: 200 }) + title: string; + + @Column({ type: 'text', nullable: true }) + body: string; + + @Column({ type: 'jsonb', default: {} }) + data: Record; + + @Index() + @Column({ type: 'varchar', length: 50, nullable: true }) + category: NotificationCategory; + + // Envío + @Column({ name: 'sent_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + sentAt: Date; + + @Column({ name: 'provider_message_id', type: 'varchar', length: 255, nullable: true }) + providerMessageId: string; + + // Estado + @Column({ type: 'varchar', length: 20, default: 'sent' }) + status: NotificationStatus; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/mobile/entities/push-token.entity.ts b/src/modules/mobile/entities/push-token.entity.ts new file mode 100644 index 0000000..d6e04a3 --- /dev/null +++ b/src/modules/mobile/entities/push-token.entity.ts @@ -0,0 +1,65 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type PushProvider = 'firebase' | 'apns' | 'fcm'; + +@Entity({ name: 'push_tokens', schema: 'mobile' }) +@Unique(['deviceId', 'platform']) +export class PushToken { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'device_id', type: 'uuid' }) + deviceId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Token + @Column({ type: 'text' }) + token: string; + + @Column({ type: 'varchar', length: 20 }) + platform: string; // ios, android + + @Column({ type: 'varchar', length: 30, default: 'firebase' }) + provider: PushProvider; + + // Estado + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_valid', type: 'boolean', default: true }) + isValid: boolean; + + @Column({ name: 'invalid_reason', type: 'text', nullable: true }) + invalidReason: string; + + // Topics suscritos + @Column({ name: 'subscribed_topics', type: 'text', array: true, default: [] }) + subscribedTopics: string[]; + + // Ultima actividad + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/mobile/entities/sync-conflict.entity.ts b/src/modules/mobile/entities/sync-conflict.entity.ts new file mode 100644 index 0000000..a2fe214 --- /dev/null +++ b/src/modules/mobile/entities/sync-conflict.entity.ts @@ -0,0 +1,63 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { OfflineSyncQueue } from './offline-sync-queue.entity'; + +export type ConflictType = 'data_conflict' | 'version_conflict' | 'delete_conflict' | 'constraint_conflict'; +export type ConflictResolutionType = 'local_wins' | 'server_wins' | 'merged' | 'manual'; + +/** + * Entidad para registrar conflictos de sincronizacion offline. + * Mapea a mobile.sync_conflicts (DDL: 04-mobile.sql) + */ +@Entity({ name: 'sync_conflicts', schema: 'mobile' }) +export class SyncConflict { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'sync_queue_id', type: 'uuid' }) + syncQueueId: string; + + @ManyToOne(() => OfflineSyncQueue, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'sync_queue_id' }) + syncQueue: OfflineSyncQueue; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'conflict_type', type: 'varchar', length: 30 }) + conflictType: ConflictType; + + @Column({ name: 'local_data', type: 'jsonb' }) + localData: Record; + + @Column({ name: 'server_data', type: 'jsonb' }) + serverData: Record; + + @Column({ type: 'varchar', length: 20, nullable: true }) + resolution: ConflictResolutionType; + + @Column({ name: 'merged_data', type: 'jsonb', nullable: true }) + mergedData: Record; + + @Column({ name: 'resolved_by', type: 'uuid', nullable: true }) + resolvedBy: string; + + @Column({ name: 'resolved_at', type: 'timestamptz', nullable: true }) + resolvedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/notifications/controllers/index.ts b/src/modules/notifications/controllers/index.ts new file mode 100644 index 0000000..ecc26de --- /dev/null +++ b/src/modules/notifications/controllers/index.ts @@ -0,0 +1 @@ +export { NotificationsController } from './notifications.controller'; diff --git a/src/modules/notifications/controllers/notifications.controller.ts b/src/modules/notifications/controllers/notifications.controller.ts new file mode 100644 index 0000000..e7db98d --- /dev/null +++ b/src/modules/notifications/controllers/notifications.controller.ts @@ -0,0 +1,257 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { NotificationsService } from '../services/notifications.service'; + +export class NotificationsController { + public router: Router; + + constructor(private readonly notificationsService: NotificationsService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Channels + this.router.get('/channels', this.findAllChannels.bind(this)); + this.router.get('/channels/:code', this.findChannelByCode.bind(this)); + + // Templates + this.router.get('/templates', this.findAllTemplates.bind(this)); + this.router.get('/templates/:code', this.findTemplateByCode.bind(this)); + this.router.post('/templates', this.createTemplate.bind(this)); + + // Preferences + this.router.get('/preferences', this.getPreferences.bind(this)); + this.router.patch('/preferences', this.updatePreferences.bind(this)); + + // Notifications + this.router.post('/', this.createNotification.bind(this)); + this.router.get('/pending', this.findPendingNotifications.bind(this)); + this.router.patch('/:id/status', this.updateNotificationStatus.bind(this)); + + // In-App Notifications + this.router.get('/in-app', this.findInAppNotifications.bind(this)); + this.router.get('/in-app/unread-count', this.getUnreadCount.bind(this)); + this.router.post('/in-app/:id/read', this.markAsRead.bind(this)); + this.router.post('/in-app/read-all', this.markAllAsRead.bind(this)); + this.router.post('/in-app', this.createInAppNotification.bind(this)); + } + + // ============================================ + // CHANNELS + // ============================================ + + private async findAllChannels(req: Request, res: Response, next: NextFunction): Promise { + try { + const channels = await this.notificationsService.findAllChannels(); + res.json({ data: channels }); + } catch (error) { + next(error); + } + } + + private async findChannelByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const channel = await this.notificationsService.findChannelByCode(code); + + if (!channel) { + res.status(404).json({ error: 'Channel not found' }); + return; + } + + res.json({ data: channel }); + } catch (error) { + next(error); + } + } + + // ============================================ + // TEMPLATES + // ============================================ + + private async findAllTemplates(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const templates = await this.notificationsService.findAllTemplates(tenantId); + res.json({ data: templates, total: templates.length }); + } catch (error) { + next(error); + } + } + + private async findTemplateByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const tenantId = req.headers['x-tenant-id'] as string; + const channelType = req.query.channelType as string; + + if (!channelType) { + res.status(400).json({ error: 'channelType query parameter is required' }); + return; + } + + const template = await this.notificationsService.findTemplateByCode(code, channelType, tenantId); + + if (!template) { + res.status(404).json({ error: 'Template not found' }); + return; + } + + res.json({ data: template }); + } catch (error) { + next(error); + } + } + + private async createTemplate(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const template = await this.notificationsService.createTemplate(tenantId, req.body, userId); + res.status(201).json({ data: template }); + } catch (error) { + next(error); + } + } + + // ============================================ + // PREFERENCES + // ============================================ + + private async getPreferences(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const preferences = await this.notificationsService.getPreferences(userId, tenantId); + res.json({ data: preferences }); + } catch (error) { + next(error); + } + } + + private async updatePreferences(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const preferences = await this.notificationsService.updatePreferences(userId, tenantId, req.body); + res.json({ data: preferences }); + } catch (error) { + next(error); + } + } + + // ============================================ + // NOTIFICATIONS + // ============================================ + + private async createNotification(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + const notification = await this.notificationsService.createNotification(tenantId, req.body); + res.status(201).json({ data: notification }); + } catch (error) { + next(error); + } + } + + private async findPendingNotifications(req: Request, res: Response, next: NextFunction): Promise { + try { + const limit = parseInt(req.query.limit as string) || 100; + const notifications = await this.notificationsService.findPendingNotifications(limit); + res.json({ data: notifications, total: notifications.length }); + } catch (error) { + next(error); + } + } + + private async updateNotificationStatus(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { status, errorMessage } = req.body; + + const notification = await this.notificationsService.updateNotificationStatus(id, status, errorMessage); + + if (!notification) { + res.status(404).json({ error: 'Notification not found' }); + return; + } + + res.json({ data: notification }); + } catch (error) { + next(error); + } + } + + // ============================================ + // IN-APP NOTIFICATIONS + // ============================================ + + private async findInAppNotifications(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const includeRead = req.query.includeRead === 'true'; + + const notifications = await this.notificationsService.findInAppNotifications(userId, tenantId, includeRead); + res.json({ data: notifications, total: notifications.length }); + } catch (error) { + next(error); + } + } + + private async getUnreadCount(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const count = await this.notificationsService.getUnreadCount(userId, tenantId); + res.json({ data: { unreadCount: count } }); + } catch (error) { + next(error); + } + } + + private async markAsRead(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const marked = await this.notificationsService.markAsRead(id); + + if (!marked) { + res.status(404).json({ error: 'Notification not found or already read' }); + return; + } + + res.json({ data: { success: true } }); + } catch (error) { + next(error); + } + } + + private async markAllAsRead(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const count = await this.notificationsService.markAllAsRead(userId, tenantId); + res.json({ data: { markedCount: count } }); + } catch (error) { + next(error); + } + } + + private async createInAppNotification(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { userId, ...data } = req.body; + + const notification = await this.notificationsService.createInAppNotification(tenantId, userId, data); + res.status(201).json({ data: notification }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/notifications/dto/index.ts b/src/modules/notifications/dto/index.ts new file mode 100644 index 0000000..eef54b6 --- /dev/null +++ b/src/modules/notifications/dto/index.ts @@ -0,0 +1,8 @@ +export { + CreateNotificationTemplateDto, + UpdateNotificationTemplateDto, + UpdateNotificationPreferenceDto, + CreateNotificationDto, + UpdateNotificationStatusDto, + CreateInAppNotificationDto, +} from './notification.dto'; diff --git a/src/modules/notifications/dto/notification.dto.ts b/src/modules/notifications/dto/notification.dto.ts new file mode 100644 index 0000000..bd80a9d --- /dev/null +++ b/src/modules/notifications/dto/notification.dto.ts @@ -0,0 +1,256 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsObject, + IsUUID, + IsEnum, + IsEmail, + MaxLength, + MinLength, +} from 'class-validator'; + +// ============================================ +// TEMPLATE DTOs +// ============================================ + +export class CreateNotificationTemplateDto { + @IsString() + @MinLength(2) + @MaxLength(50) + code: string; + + @IsString() + @MinLength(2) + @MaxLength(100) + name: string; + + @IsString() + @MaxLength(20) + channelType: string; + + @IsOptional() + @IsString() + description?: string; + + @IsString() + subject: string; + + @IsString() + bodyTemplate: string; + + @IsOptional() + @IsString() + htmlTemplate?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + variables?: string[]; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class UpdateNotificationTemplateDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + subject?: string; + + @IsOptional() + @IsString() + bodyTemplate?: string; + + @IsOptional() + @IsString() + htmlTemplate?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + variables?: string[]; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +// ============================================ +// PREFERENCE DTOs +// ============================================ + +export class UpdateNotificationPreferenceDto { + @IsOptional() + @IsBoolean() + emailEnabled?: boolean; + + @IsOptional() + @IsBoolean() + smsEnabled?: boolean; + + @IsOptional() + @IsBoolean() + pushEnabled?: boolean; + + @IsOptional() + @IsBoolean() + inAppEnabled?: boolean; + + @IsOptional() + @IsBoolean() + whatsappEnabled?: boolean; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + mutedCategories?: string[]; + + @IsOptional() + @IsObject() + quietHours?: { + enabled: boolean; + startTime?: string; + endTime?: string; + timezone?: string; + }; + + @IsOptional() + @IsString() + @MaxLength(10) + language?: string; + + @IsOptional() + @IsString() + digestFrequency?: string; +} + +// ============================================ +// NOTIFICATION DTOs +// ============================================ + +export class CreateNotificationDto { + @IsUUID() + userId: string; + + @IsString() + @MaxLength(50) + channelType: string; + + @IsOptional() + @IsUUID() + templateId?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + category?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + subject?: string; + + @IsString() + content: string; + + @IsOptional() + @IsString() + htmlContent?: string; + + @IsOptional() + @IsObject() + templateData?: Record; + + @IsOptional() + @IsString() + @MaxLength(10) + priority?: string; + + @IsOptional() + @IsString() + scheduledFor?: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class UpdateNotificationStatusDto { + @IsString() + @MaxLength(20) + status: string; + + @IsOptional() + @IsString() + errorMessage?: string; +} + +// ============================================ +// IN-APP NOTIFICATION DTOs +// ============================================ + +export class CreateInAppNotificationDto { + @IsUUID() + userId: string; + + @IsString() + @MaxLength(200) + title: string; + + @IsString() + message: string; + + @IsOptional() + @IsString() + @MaxLength(30) + type?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + category?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + priority?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + actionUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + actionLabel?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + imageUrl?: string; + + @IsOptional() + @IsObject() + data?: Record; + + @IsOptional() + @IsString() + expiresAt?: string; +} diff --git a/src/modules/notifications/entities/channel.entity.ts b/src/modules/notifications/entities/channel.entity.ts new file mode 100644 index 0000000..8b00b62 --- /dev/null +++ b/src/modules/notifications/entities/channel.entity.ts @@ -0,0 +1,59 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type ChannelType = 'email' | 'sms' | 'push' | 'whatsapp' | 'in_app' | 'webhook'; + +@Entity({ name: 'channels', schema: 'notifications' }) +export class Channel { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index({ unique: true }) + @Column({ name: 'code', type: 'varchar', length: 30 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'channel_type', type: 'varchar', length: 30 }) + channelType: ChannelType; + + @Column({ name: 'provider', type: 'varchar', length: 50, nullable: true }) + provider: string; + + @Column({ name: 'provider_config', type: 'jsonb', default: {} }) + providerConfig: Record; + + @Column({ name: 'rate_limit_per_minute', type: 'int', default: 60 }) + rateLimitPerMinute: number; + + @Column({ name: 'rate_limit_per_hour', type: 'int', default: 1000 }) + rateLimitPerHour: number; + + @Column({ name: 'rate_limit_per_day', type: 'int', default: 10000 }) + rateLimitPerDay: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/notifications/entities/in-app-notification.entity.ts b/src/modules/notifications/entities/in-app-notification.entity.ts new file mode 100644 index 0000000..44b26d9 --- /dev/null +++ b/src/modules/notifications/entities/in-app-notification.entity.ts @@ -0,0 +1,78 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type InAppCategory = 'info' | 'success' | 'warning' | 'error' | 'task'; +export type InAppPriority = 'low' | 'normal' | 'high' | 'urgent'; +export type InAppActionType = 'link' | 'modal' | 'function'; + +@Entity({ name: 'in_app_notifications', schema: 'notifications' }) +export class InAppNotification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'title', type: 'varchar', length: 200 }) + title: string; + + @Column({ name: 'message', type: 'text' }) + message: string; + + @Column({ name: 'icon', type: 'varchar', length: 50, nullable: true }) + icon: string; + + @Column({ name: 'color', type: 'varchar', length: 20, nullable: true }) + color: string; + + @Column({ name: 'action_type', type: 'varchar', length: 30, nullable: true }) + actionType: InAppActionType; + + @Column({ name: 'action_url', type: 'text', nullable: true }) + actionUrl: string; + + @Column({ name: 'action_data', type: 'jsonb', default: {} }) + actionData: Record; + + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: InAppCategory; + + @Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true }) + contextType: string; + + @Column({ name: 'context_id', type: 'uuid', nullable: true }) + contextId: string; + + @Index() + @Column({ name: 'is_read', type: 'boolean', default: false }) + isRead: boolean; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @Column({ name: 'is_archived', type: 'boolean', default: false }) + isArchived: boolean; + + @Column({ name: 'archived_at', type: 'timestamptz', nullable: true }) + archivedAt: Date; + + @Column({ name: 'priority', type: 'varchar', length: 20, default: 'normal' }) + priority: InAppPriority; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; +} diff --git a/src/modules/notifications/entities/index.ts b/src/modules/notifications/entities/index.ts new file mode 100644 index 0000000..78ee483 --- /dev/null +++ b/src/modules/notifications/entities/index.ts @@ -0,0 +1,6 @@ +export { Channel, ChannelType } from './channel.entity'; +export { NotificationTemplate, TemplateTranslation, TemplateCategory } from './template.entity'; +export { NotificationPreference, DigestFrequency } from './preference.entity'; +export { Notification, NotificationStatus, NotificationPriority } from './notification.entity'; +export { NotificationBatch, BatchStatus, AudienceType } from './notification-batch.entity'; +export { InAppNotification, InAppCategory, InAppPriority, InAppActionType } from './in-app-notification.entity'; diff --git a/src/modules/notifications/entities/notification-batch.entity.ts b/src/modules/notifications/entities/notification-batch.entity.ts new file mode 100644 index 0000000..4873006 --- /dev/null +++ b/src/modules/notifications/entities/notification-batch.entity.ts @@ -0,0 +1,88 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ChannelType } from './channel.entity'; +import { NotificationTemplate } from './template.entity'; + +export type BatchStatus = 'draft' | 'scheduled' | 'processing' | 'completed' | 'failed' | 'cancelled'; +export type AudienceType = 'all_users' | 'segment' | 'custom'; + +@Entity({ name: 'notification_batches', schema: 'notifications' }) +export class NotificationBatch { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'template_id', type: 'uuid', nullable: true }) + templateId: string; + + @Column({ name: 'channel_type', type: 'varchar', length: 30 }) + channelType: ChannelType; + + @Column({ name: 'audience_type', type: 'varchar', length: 30 }) + audienceType: AudienceType; + + @Column({ name: 'audience_filter', type: 'jsonb', default: {} }) + audienceFilter: Record; + + @Column({ name: 'variables', type: 'jsonb', default: {} }) + variables: Record; + + @Index() + @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) + scheduledAt: Date; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'draft' }) + status: BatchStatus; + + @Column({ name: 'total_recipients', type: 'int', default: 0 }) + totalRecipients: number; + + @Column({ name: 'sent_count', type: 'int', default: 0 }) + sentCount: number; + + @Column({ name: 'delivered_count', type: 'int', default: 0 }) + deliveredCount: number; + + @Column({ name: 'failed_count', type: 'int', default: 0 }) + failedCount: number; + + @Column({ name: 'read_count', type: 'int', default: 0 }) + readCount: number; + + @Column({ name: 'started_at', type: 'timestamptz', nullable: true }) + startedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => NotificationTemplate, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'template_id' }) + template: NotificationTemplate; +} diff --git a/src/modules/notifications/entities/notification.entity.ts b/src/modules/notifications/entities/notification.entity.ts new file mode 100644 index 0000000..2fb0744 --- /dev/null +++ b/src/modules/notifications/entities/notification.entity.ts @@ -0,0 +1,131 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ChannelType, Channel } from './channel.entity'; +import { NotificationTemplate } from './template.entity'; + +export type NotificationStatus = 'pending' | 'queued' | 'sending' | 'sent' | 'delivered' | 'read' | 'failed' | 'cancelled'; +export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent'; + +@Entity({ name: 'notifications', schema: 'notifications' }) +export class Notification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'recipient_email', type: 'varchar', length: 255, nullable: true }) + recipientEmail: string; + + @Column({ name: 'recipient_phone', type: 'varchar', length: 20, nullable: true }) + recipientPhone: string; + + @Column({ name: 'recipient_device_id', type: 'uuid', nullable: true }) + recipientDeviceId: string; + + @Index() + @Column({ name: 'template_id', type: 'uuid', nullable: true }) + templateId: string; + + @Column({ name: 'template_code', type: 'varchar', length: 100, nullable: true }) + templateCode: string; + + @Index() + @Column({ name: 'channel_type', type: 'varchar', length: 30 }) + channelType: ChannelType; + + @Column({ name: 'channel_id', type: 'uuid', nullable: true }) + channelId: string; + + @Column({ name: 'subject', type: 'varchar', length: 500, nullable: true }) + subject: string; + + @Column({ name: 'body', type: 'text' }) + body: string; + + @Column({ name: 'body_html', type: 'text', nullable: true }) + bodyHtml: string; + + @Column({ name: 'variables', type: 'jsonb', default: {} }) + variables: Record; + + @Index() + @Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true }) + contextType: string; + + @Column({ name: 'context_id', type: 'uuid', nullable: true }) + contextId: string; + + @Column({ name: 'priority', type: 'varchar', length: 20, default: 'normal' }) + priority: NotificationPriority; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: NotificationStatus; + + @Column({ name: 'queued_at', type: 'timestamptz', nullable: true }) + queuedAt: Date; + + @Column({ name: 'sent_at', type: 'timestamptz', nullable: true }) + sentAt: Date; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @Column({ name: 'failed_at', type: 'timestamptz', nullable: true }) + failedAt: Date; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'retry_count', type: 'int', default: 0 }) + retryCount: number; + + @Column({ name: 'max_retries', type: 'int', default: 3 }) + maxRetries: number; + + @Column({ name: 'next_retry_at', type: 'timestamptz', nullable: true }) + nextRetryAt: Date; + + @Column({ name: 'provider_message_id', type: 'varchar', length: 255, nullable: true }) + providerMessageId: string; + + @Column({ name: 'provider_response', type: 'jsonb', default: {} }) + providerResponse: Record; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => NotificationTemplate, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'template_id' }) + template: NotificationTemplate; + + @ManyToOne(() => Channel, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'channel_id' }) + channel: Channel; +} diff --git a/src/modules/notifications/entities/preference.entity.ts b/src/modules/notifications/entities/preference.entity.ts new file mode 100644 index 0000000..a1fc7de --- /dev/null +++ b/src/modules/notifications/entities/preference.entity.ts @@ -0,0 +1,74 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type DigestFrequency = 'instant' | 'hourly' | 'daily' | 'weekly'; + +@Entity({ name: 'preferences', schema: 'notifications' }) +@Unique(['userId', 'tenantId']) +export class NotificationPreference { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'global_enabled', type: 'boolean', default: true }) + globalEnabled: boolean; + + @Column({ name: 'quiet_hours_start', type: 'time', nullable: true }) + quietHoursStart: string; + + @Column({ name: 'quiet_hours_end', type: 'time', nullable: true }) + quietHoursEnd: string; + + @Column({ name: 'timezone', type: 'varchar', length: 50, default: 'America/Mexico_City' }) + timezone: string; + + @Column({ name: 'email_enabled', type: 'boolean', default: true }) + emailEnabled: boolean; + + @Column({ name: 'sms_enabled', type: 'boolean', default: true }) + smsEnabled: boolean; + + @Column({ name: 'push_enabled', type: 'boolean', default: true }) + pushEnabled: boolean; + + @Column({ name: 'whatsapp_enabled', type: 'boolean', default: false }) + whatsappEnabled: boolean; + + @Column({ name: 'in_app_enabled', type: 'boolean', default: true }) + inAppEnabled: boolean; + + @Column({ name: 'category_preferences', type: 'jsonb', default: {} }) + categoryPreferences: Record; + + @Column({ name: 'digest_frequency', type: 'varchar', length: 20, default: 'instant' }) + digestFrequency: DigestFrequency; + + @Column({ name: 'digest_day', type: 'int', nullable: true }) + digestDay: number; + + @Column({ name: 'digest_hour', type: 'int', default: 9 }) + digestHour: number; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/notifications/entities/template.entity.ts b/src/modules/notifications/entities/template.entity.ts new file mode 100644 index 0000000..e5be08f --- /dev/null +++ b/src/modules/notifications/entities/template.entity.ts @@ -0,0 +1,118 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, + Unique, +} from 'typeorm'; +import { ChannelType } from './channel.entity'; + +export type TemplateCategory = 'system' | 'marketing' | 'transactional' | 'alert'; + +@Entity({ name: 'templates', schema: 'notifications' }) +@Unique(['tenantId', 'code', 'channelType']) +export class NotificationTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Index() + @Column({ name: 'code', type: 'varchar', length: 100 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: TemplateCategory; + + @Index() + @Column({ name: 'channel_type', type: 'varchar', length: 30 }) + channelType: ChannelType; + + @Column({ name: 'subject', type: 'varchar', length: 500, nullable: true }) + subject: string; + + @Column({ name: 'body_template', type: 'text' }) + bodyTemplate: string; + + @Column({ name: 'body_html', type: 'text', nullable: true }) + bodyHtml: string; + + @Column({ name: 'available_variables', type: 'jsonb', default: [] }) + availableVariables: string[]; + + @Column({ name: 'default_locale', type: 'varchar', length: 10, default: 'es-MX' }) + defaultLocale: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem: boolean; + + @Column({ name: 'version', type: 'int', default: 1 }) + version: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @OneToMany(() => TemplateTranslation, (translation) => translation.template) + translations: TemplateTranslation[]; +} + +@Entity({ name: 'template_translations', schema: 'notifications' }) +@Unique(['templateId', 'locale']) +export class TemplateTranslation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'template_id', type: 'uuid' }) + templateId: string; + + @Column({ name: 'locale', type: 'varchar', length: 10 }) + locale: string; + + @Column({ name: 'subject', type: 'varchar', length: 500, nullable: true }) + subject: string; + + @Column({ name: 'body_template', type: 'text' }) + bodyTemplate: string; + + @Column({ name: 'body_html', type: 'text', nullable: true }) + bodyHtml: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => NotificationTemplate, (template) => template.translations, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'template_id' }) + template: NotificationTemplate; +} diff --git a/src/modules/notifications/index.ts b/src/modules/notifications/index.ts new file mode 100644 index 0000000..ce95f7e --- /dev/null +++ b/src/modules/notifications/index.ts @@ -0,0 +1,5 @@ +export { NotificationsModule, NotificationsModuleOptions } from './notifications.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/notifications/notifications.module.ts b/src/modules/notifications/notifications.module.ts new file mode 100644 index 0000000..0409174 --- /dev/null +++ b/src/modules/notifications/notifications.module.ts @@ -0,0 +1,68 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { NotificationsService } from './services'; +import { NotificationsController } from './controllers'; +import { + Channel, + NotificationTemplate, + NotificationTemplateTranslation, + NotificationPreference, + Notification, + NotificationBatch, + InAppNotification, +} from './entities'; + +export interface NotificationsModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class NotificationsModule { + public router: Router; + public notificationsService: NotificationsService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: NotificationsModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const channelRepository = this.dataSource.getRepository(Channel); + const templateRepository = this.dataSource.getRepository(NotificationTemplate); + const preferenceRepository = this.dataSource.getRepository(NotificationPreference); + const notificationRepository = this.dataSource.getRepository(Notification); + const batchRepository = this.dataSource.getRepository(NotificationBatch); + const inAppRepository = this.dataSource.getRepository(InAppNotification); + + this.notificationsService = new NotificationsService( + channelRepository, + templateRepository, + preferenceRepository, + notificationRepository, + batchRepository, + inAppRepository + ); + } + + private initializeRoutes(): void { + const notificationsController = new NotificationsController(this.notificationsService); + this.router.use(`${this.basePath}/notifications`, notificationsController.router); + } + + static getEntities(): Function[] { + return [ + Channel, + NotificationTemplate, + NotificationTemplateTranslation, + NotificationPreference, + Notification, + NotificationBatch, + InAppNotification, + ]; + } +} diff --git a/src/modules/notifications/services/index.ts b/src/modules/notifications/services/index.ts new file mode 100644 index 0000000..cf62754 --- /dev/null +++ b/src/modules/notifications/services/index.ts @@ -0,0 +1 @@ +export { NotificationsService } from './notifications.service'; diff --git a/src/modules/notifications/services/notifications.service.ts b/src/modules/notifications/services/notifications.service.ts new file mode 100644 index 0000000..e54d653 --- /dev/null +++ b/src/modules/notifications/services/notifications.service.ts @@ -0,0 +1,215 @@ +import { Repository, FindOptionsWhere } from 'typeorm'; +import { + Channel, + NotificationTemplate, + NotificationPreference, + Notification, + NotificationBatch, + InAppNotification, +} from '../entities'; + +export class NotificationsService { + constructor( + private readonly channelRepository: Repository, + private readonly templateRepository: Repository, + private readonly preferenceRepository: Repository, + private readonly notificationRepository: Repository, + private readonly batchRepository: Repository, + private readonly inAppRepository: Repository + ) {} + + // ============================================ + // CHANNELS + // ============================================ + + async findAllChannels(): Promise { + return this.channelRepository.find({ + where: { isActive: true }, + order: { name: 'ASC' }, + }); + } + + async findChannelByCode(code: string): Promise { + return this.channelRepository.findOne({ where: { code } }); + } + + // ============================================ + // TEMPLATES + // ============================================ + + async findAllTemplates(tenantId: string): Promise { + return this.templateRepository.find({ + where: [{ tenantId }, { tenantId: undefined, isActive: true }], + relations: ['translations'], + order: { name: 'ASC' }, + }); + } + + async findTemplateByCode( + code: string, + channelType: string, + tenantId?: string + ): Promise { + const where: FindOptionsWhere[] = tenantId + ? [{ code, channelType, tenantId }, { code, channelType, tenantId: undefined }] + : [{ code, channelType }]; + + return this.templateRepository.findOne({ + where, + relations: ['translations'], + order: { tenantId: 'DESC' }, + }); + } + + async createTemplate( + tenantId: string, + data: Partial, + createdBy?: string + ): Promise { + const template = this.templateRepository.create({ + ...data, + tenantId, + createdBy, + }); + return this.templateRepository.save(template); + } + + // ============================================ + // PREFERENCES + // ============================================ + + async getPreferences(userId: string, tenantId: string): Promise { + return this.preferenceRepository.findOne({ + where: { userId, tenantId }, + }); + } + + async updatePreferences( + userId: string, + tenantId: string, + data: Partial + ): Promise { + let preferences = await this.getPreferences(userId, tenantId); + + if (!preferences) { + preferences = this.preferenceRepository.create({ + userId, + tenantId, + ...data, + }); + } else { + Object.assign(preferences, data); + } + + return this.preferenceRepository.save(preferences); + } + + // ============================================ + // NOTIFICATIONS + // ============================================ + + async createNotification( + tenantId: string, + data: Partial + ): Promise { + const notification = this.notificationRepository.create({ + ...data, + tenantId, + status: 'pending', + }); + return this.notificationRepository.save(notification); + } + + async findPendingNotifications(limit: number = 100): Promise { + return this.notificationRepository.find({ + where: { status: 'pending' }, + order: { createdAt: 'ASC' }, + take: limit, + }); + } + + async updateNotificationStatus( + id: string, + status: string, + errorMessage?: string + ): Promise { + const notification = await this.notificationRepository.findOne({ where: { id } }); + if (!notification) return null; + + notification.status = status as any; + if (errorMessage) notification.errorMessage = errorMessage; + if (status === 'sent') notification.sentAt = new Date(); + if (status === 'delivered') notification.deliveredAt = new Date(); + if (status === 'failed') notification.failedAt = new Date(); + + return this.notificationRepository.save(notification); + } + + // ============================================ + // IN-APP NOTIFICATIONS + // ============================================ + + async findInAppNotifications( + userId: string, + tenantId: string, + includeRead: boolean = false + ): Promise { + const where: FindOptionsWhere = { + userId, + tenantId, + isArchived: false, + }; + + if (!includeRead) { + where.isRead = false; + } + + return this.inAppRepository.find({ + where, + order: { createdAt: 'DESC' }, + take: 50, + }); + } + + async getUnreadCount(userId: string, tenantId: string): Promise { + return this.inAppRepository.count({ + where: { + userId, + tenantId, + isRead: false, + isArchived: false, + }, + }); + } + + async markAsRead(id: string): Promise { + const notification = await this.inAppRepository.findOne({ where: { id } }); + if (!notification || notification.isRead) return false; + + notification.isRead = true; + notification.readAt = new Date(); + await this.inAppRepository.save(notification); + return true; + } + + async markAllAsRead(userId: string, tenantId: string): Promise { + const result = await this.inAppRepository.update( + { userId, tenantId, isRead: false }, + { isRead: true, readAt: new Date() } + ); + return result.affected || 0; + } + + async createInAppNotification( + tenantId: string, + userId: string, + data: Partial + ): Promise { + const notification = this.inAppRepository.create({ + ...data, + tenantId, + userId, + }); + return this.inAppRepository.save(notification); + } +} diff --git a/src/modules/partners/controllers/index.ts b/src/modules/partners/controllers/index.ts new file mode 100644 index 0000000..66e2ab7 --- /dev/null +++ b/src/modules/partners/controllers/index.ts @@ -0,0 +1 @@ +export { PartnersController } from './partners.controller'; diff --git a/src/modules/partners/controllers/partners.controller.ts b/src/modules/partners/controllers/partners.controller.ts new file mode 100644 index 0000000..1afaec1 --- /dev/null +++ b/src/modules/partners/controllers/partners.controller.ts @@ -0,0 +1,348 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { PartnersService } from '../services/partners.service'; +import { + CreatePartnerDto, + UpdatePartnerDto, + CreatePartnerAddressDto, + CreatePartnerContactDto, + CreatePartnerBankAccountDto, +} from '../dto'; + +export class PartnersController { + public router: Router; + + constructor(private readonly partnersService: PartnersService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Partners + this.router.get('/', this.findAll.bind(this)); + this.router.get('/customers', this.getCustomers.bind(this)); + this.router.get('/suppliers', this.getSuppliers.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.get('/code/:code', this.findByCode.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + + // Addresses + this.router.get('/:id/addresses', this.getAddresses.bind(this)); + this.router.post('/:id/addresses', this.createAddress.bind(this)); + this.router.delete('/:id/addresses/:addressId', this.deleteAddress.bind(this)); + + // Contacts + this.router.get('/:id/contacts', this.getContacts.bind(this)); + this.router.post('/:id/contacts', this.createContact.bind(this)); + this.router.delete('/:id/contacts/:contactId', this.deleteContact.bind(this)); + + // Bank Accounts + this.router.get('/:id/bank-accounts', this.getBankAccounts.bind(this)); + this.router.post('/:id/bank-accounts', this.createBankAccount.bind(this)); + this.router.delete('/:id/bank-accounts/:accountId', this.deleteBankAccount.bind(this)); + this.router.post('/:id/bank-accounts/:accountId/verify', this.verifyBankAccount.bind(this)); + } + + // ==================== Partners ==================== + + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { search, partnerType, category, isActive, salesRepId, limit, offset } = req.query; + + const result = await this.partnersService.findAll({ + tenantId, + search: search as string, + partnerType: partnerType as 'customer' | 'supplier' | 'both', + category: category as string, + isActive: isActive ? isActive === 'true' : undefined, + salesRepId: salesRepId as string, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const partner = await this.partnersService.findOne(id, tenantId); + + if (!partner) { + res.status(404).json({ error: 'Partner not found' }); + return; + } + + res.json({ data: partner }); + } catch (error) { + next(error); + } + } + + private async findByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { code } = req.params; + const partner = await this.partnersService.findByCode(code, tenantId); + + if (!partner) { + res.status(404).json({ error: 'Partner not found' }); + return; + } + + res.json({ data: partner }); + } catch (error) { + next(error); + } + } + + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: CreatePartnerDto = req.body; + const partner = await this.partnersService.create(tenantId, dto, userId); + res.status(201).json({ data: partner }); + } catch (error) { + next(error); + } + } + + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const dto: UpdatePartnerDto = req.body; + const partner = await this.partnersService.update(id, tenantId, dto, userId); + + if (!partner) { + res.status(404).json({ error: 'Partner not found' }); + return; + } + + res.json({ data: partner }); + } catch (error) { + next(error); + } + } + + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const deleted = await this.partnersService.delete(id, tenantId); + + if (!deleted) { + res.status(404).json({ error: 'Partner not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async getCustomers(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const customers = await this.partnersService.getCustomers(tenantId); + res.json({ data: customers }); + } catch (error) { + next(error); + } + } + + private async getSuppliers(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const suppliers = await this.partnersService.getSuppliers(tenantId); + res.json({ data: suppliers }); + } catch (error) { + next(error); + } + } + + // ==================== Addresses ==================== + + private async getAddresses(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const addresses = await this.partnersService.getAddresses(id); + res.json({ data: addresses }); + } catch (error) { + next(error); + } + } + + private async createAddress(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const dto: CreatePartnerAddressDto = { ...req.body, partnerId: id }; + const address = await this.partnersService.createAddress(dto); + res.status(201).json({ data: address }); + } catch (error) { + next(error); + } + } + + private async deleteAddress(req: Request, res: Response, next: NextFunction): Promise { + try { + const { addressId } = req.params; + const deleted = await this.partnersService.deleteAddress(addressId); + + if (!deleted) { + res.status(404).json({ error: 'Address not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ==================== Contacts ==================== + + private async getContacts(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const contacts = await this.partnersService.getContacts(id); + res.json({ data: contacts }); + } catch (error) { + next(error); + } + } + + private async createContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const dto: CreatePartnerContactDto = { ...req.body, partnerId: id }; + const contact = await this.partnersService.createContact(dto); + res.status(201).json({ data: contact }); + } catch (error) { + next(error); + } + } + + private async deleteContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const { contactId } = req.params; + const deleted = await this.partnersService.deleteContact(contactId); + + if (!deleted) { + res.status(404).json({ error: 'Contact not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ==================== Bank Accounts ==================== + + private async getBankAccounts(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const bankAccounts = await this.partnersService.getBankAccounts(id); + res.json({ data: bankAccounts }); + } catch (error) { + next(error); + } + } + + private async createBankAccount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const dto: CreatePartnerBankAccountDto = { ...req.body, partnerId: id }; + const bankAccount = await this.partnersService.createBankAccount(dto); + res.status(201).json({ data: bankAccount }); + } catch (error) { + next(error); + } + } + + private async deleteBankAccount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { accountId } = req.params; + const deleted = await this.partnersService.deleteBankAccount(accountId); + + if (!deleted) { + res.status(404).json({ error: 'Bank account not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async verifyBankAccount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { accountId } = req.params; + const bankAccount = await this.partnersService.verifyBankAccount(accountId); + + if (!bankAccount) { + res.status(404).json({ error: 'Bank account not found' }); + return; + } + + res.json({ data: bankAccount }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/partners/dto/create-partner.dto.ts b/src/modules/partners/dto/create-partner.dto.ts new file mode 100644 index 0000000..275501a --- /dev/null +++ b/src/modules/partners/dto/create-partner.dto.ts @@ -0,0 +1,389 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsEmail, + IsNumber, + IsArray, + IsUUID, + MaxLength, + IsEnum, + Min, + Max, +} from 'class-validator'; + +export class CreatePartnerDto { + @IsString() + @MaxLength(20) + code: string; + + @IsString() + @MaxLength(200) + displayName: string; + + @IsOptional() + @IsString() + @MaxLength(200) + legalName?: string; + + @IsOptional() + @IsEnum(['customer', 'supplier', 'both']) + partnerType?: 'customer' | 'supplier' | 'both'; + + @IsOptional() + @IsString() + @MaxLength(20) + taxId?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + taxRegime?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + cfdiUse?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + mobile?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + website?: string; + + @IsOptional() + @IsNumber() + @Min(0) + paymentTermDays?: number; + + @IsOptional() + @IsNumber() + @Min(0) + creditLimit?: number; + + @IsOptional() + @IsUUID() + priceListId?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + discountPercent?: number; + + @IsOptional() + @IsString() + @MaxLength(50) + category?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsUUID() + salesRepId?: string; +} + +export class UpdatePartnerDto { + @IsOptional() + @IsString() + @MaxLength(20) + code?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + displayName?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + legalName?: string; + + @IsOptional() + @IsEnum(['customer', 'supplier', 'both']) + partnerType?: 'customer' | 'supplier' | 'both'; + + @IsOptional() + @IsString() + @MaxLength(20) + taxId?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + taxRegime?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + cfdiUse?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + mobile?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + website?: string; + + @IsOptional() + @IsNumber() + @Min(0) + paymentTermDays?: number; + + @IsOptional() + @IsNumber() + @Min(0) + creditLimit?: number; + + @IsOptional() + @IsUUID() + priceListId?: string; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + discountPercent?: number; + + @IsOptional() + @IsString() + @MaxLength(50) + category?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsUUID() + salesRepId?: string; +} + +export class CreatePartnerAddressDto { + @IsUUID() + partnerId: string; + + @IsOptional() + @IsEnum(['billing', 'shipping', 'both']) + addressType?: 'billing' | 'shipping' | 'both'; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @IsOptional() + @IsString() + @MaxLength(100) + label?: string; + + @IsString() + @MaxLength(200) + street: string; + + @IsOptional() + @IsString() + @MaxLength(20) + exteriorNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + interiorNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + neighborhood?: string; + + @IsString() + @MaxLength(100) + city: string; + + @IsOptional() + @IsString() + @MaxLength(100) + municipality?: string; + + @IsString() + @MaxLength(100) + state: string; + + @IsString() + @MaxLength(10) + postalCode: string; + + @IsOptional() + @IsString() + @MaxLength(3) + country?: string; + + @IsOptional() + @IsString() + reference?: string; + + @IsOptional() + @IsNumber() + latitude?: number; + + @IsOptional() + @IsNumber() + longitude?: number; +} + +export class CreatePartnerContactDto { + @IsUUID() + partnerId: string; + + @IsString() + @MaxLength(200) + fullName: string; + + @IsOptional() + @IsString() + @MaxLength(100) + position?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + department?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + mobile?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + extension?: string; + + @IsOptional() + @IsBoolean() + isPrimary?: boolean; + + @IsOptional() + @IsBoolean() + isBillingContact?: boolean; + + @IsOptional() + @IsBoolean() + isShippingContact?: boolean; + + @IsOptional() + @IsBoolean() + receivesNotifications?: boolean; + + @IsOptional() + @IsString() + notes?: string; +} + +export class CreatePartnerBankAccountDto { + @IsUUID() + partnerId: string; + + @IsString() + @MaxLength(100) + bankName: string; + + @IsOptional() + @IsString() + @MaxLength(10) + bankCode?: string; + + @IsString() + @MaxLength(30) + accountNumber: string; + + @IsOptional() + @IsString() + @MaxLength(20) + clabe?: string; + + @IsOptional() + @IsEnum(['checking', 'savings']) + accountType?: 'checking' | 'savings'; + + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + beneficiaryName?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + beneficiaryTaxId?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + swiftCode?: string; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/src/modules/partners/dto/index.ts b/src/modules/partners/dto/index.ts new file mode 100644 index 0000000..ef0bc75 --- /dev/null +++ b/src/modules/partners/dto/index.ts @@ -0,0 +1,7 @@ +export { + CreatePartnerDto, + UpdatePartnerDto, + CreatePartnerAddressDto, + CreatePartnerContactDto, + CreatePartnerBankAccountDto, +} from './create-partner.dto'; diff --git a/src/modules/partners/entities/index.ts b/src/modules/partners/entities/index.ts new file mode 100644 index 0000000..59192e9 --- /dev/null +++ b/src/modules/partners/entities/index.ts @@ -0,0 +1,4 @@ +export { Partner } from './partner.entity'; +export { PartnerAddress } from './partner-address.entity'; +export { PartnerContact } from './partner-contact.entity'; +export { PartnerBankAccount } from './partner-bank-account.entity'; diff --git a/src/modules/partners/entities/partner-address.entity.ts b/src/modules/partners/entities/partner-address.entity.ts new file mode 100644 index 0000000..566becc --- /dev/null +++ b/src/modules/partners/entities/partner-address.entity.ts @@ -0,0 +1,82 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Partner } from './partner.entity'; + +@Entity({ name: 'partner_addresses', schema: 'partners' }) +export class PartnerAddress { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @ManyToOne(() => Partner, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'partner_id' }) + partner: Partner; + + // Tipo de direccion + @Index() + @Column({ name: 'address_type', type: 'varchar', length: 20, default: 'billing' }) + addressType: 'billing' | 'shipping' | 'both'; + + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + // Direccion + @Column({ type: 'varchar', length: 100, nullable: true }) + label: string; + + @Column({ type: 'varchar', length: 200 }) + street: string; + + @Column({ name: 'exterior_number', type: 'varchar', length: 20, nullable: true }) + exteriorNumber: string; + + @Column({ name: 'interior_number', type: 'varchar', length: 20, nullable: true }) + interiorNumber: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + neighborhood: string; + + @Column({ type: 'varchar', length: 100 }) + city: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + municipality: string; + + @Column({ type: 'varchar', length: 100 }) + state: string; + + @Column({ name: 'postal_code', type: 'varchar', length: 10 }) + postalCode: string; + + @Column({ type: 'varchar', length: 3, default: 'MEX' }) + country: string; + + // Referencia + @Column({ type: 'text', nullable: true }) + reference: string; + + // Geolocalizacion + @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/partners/entities/partner-bank-account.entity.ts b/src/modules/partners/entities/partner-bank-account.entity.ts new file mode 100644 index 0000000..a5cce38 --- /dev/null +++ b/src/modules/partners/entities/partner-bank-account.entity.ts @@ -0,0 +1,77 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Partner } from './partner.entity'; + +@Entity({ name: 'partner_bank_accounts', schema: 'partners' }) +export class PartnerBankAccount { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @ManyToOne(() => Partner, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'partner_id' }) + partner: Partner; + + // Banco + @Column({ name: 'bank_name', type: 'varchar', length: 100 }) + bankName: string; + + @Column({ name: 'bank_code', type: 'varchar', length: 10, nullable: true }) + bankCode: string; + + // Cuenta + @Column({ name: 'account_number', type: 'varchar', length: 30 }) + accountNumber: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + clabe: string; + + @Column({ name: 'account_type', type: 'varchar', length: 20, default: 'checking' }) + accountType: 'checking' | 'savings'; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + // Titular + @Column({ name: 'beneficiary_name', type: 'varchar', length: 200, nullable: true }) + beneficiaryName: string; + + @Column({ name: 'beneficiary_tax_id', type: 'varchar', length: 20, nullable: true }) + beneficiaryTaxId: string; + + // Swift para transferencias internacionales + @Column({ name: 'swift_code', type: 'varchar', length: 20, nullable: true }) + swiftCode: string; + + // Flags + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + // Notas + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/partners/entities/partner-contact.entity.ts b/src/modules/partners/entities/partner-contact.entity.ts new file mode 100644 index 0000000..d4479fe --- /dev/null +++ b/src/modules/partners/entities/partner-contact.entity.ts @@ -0,0 +1,72 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Partner } from './partner.entity'; + +@Entity({ name: 'partner_contacts', schema: 'partners' }) +export class PartnerContact { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @ManyToOne(() => Partner, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'partner_id' }) + partner: Partner; + + // Datos del contacto + @Column({ name: 'full_name', type: 'varchar', length: 200 }) + fullName: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + position: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + department: string; + + // Contacto + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string; + + @Column({ type: 'varchar', length: 30, nullable: true }) + phone: string; + + @Column({ type: 'varchar', length: 30, nullable: true }) + mobile: string; + + @Column({ type: 'varchar', length: 30, nullable: true }) + extension: string; + + // Flags + @Column({ name: 'is_primary', type: 'boolean', default: false }) + isPrimary: boolean; + + @Column({ name: 'is_billing_contact', type: 'boolean', default: false }) + isBillingContact: boolean; + + @Column({ name: 'is_shipping_contact', type: 'boolean', default: false }) + isShippingContact: boolean; + + @Column({ name: 'receives_notifications', type: 'boolean', default: true }) + receivesNotifications: boolean; + + // Notas + @Column({ type: 'text', nullable: true }) + notes: string; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/partners/entities/partner.entity.ts b/src/modules/partners/entities/partner.entity.ts new file mode 100644 index 0000000..3173892 --- /dev/null +++ b/src/modules/partners/entities/partner.entity.ts @@ -0,0 +1,118 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; + +@Entity({ name: 'partners', schema: 'partners' }) +export class Partner { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 20, unique: true }) + code: string; + + @Column({ name: 'display_name', type: 'varchar', length: 200 }) + displayName: string; + + @Column({ name: 'legal_name', type: 'varchar', length: 200, nullable: true }) + legalName: string; + + // Tipo de partner + @Index() + @Column({ name: 'partner_type', type: 'varchar', length: 20, default: 'customer' }) + partnerType: 'customer' | 'supplier' | 'both'; + + // Fiscal + @Index() + @Column({ name: 'tax_id', type: 'varchar', length: 20, nullable: true }) + taxId: string; + + @Column({ name: 'tax_regime', type: 'varchar', length: 100, nullable: true }) + taxRegime: string; + + @Column({ name: 'cfdi_use', type: 'varchar', length: 10, nullable: true }) + cfdiUse: string; + + // Contacto principal + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string; + + @Column({ type: 'varchar', length: 30, nullable: true }) + phone: string; + + @Column({ type: 'varchar', length: 30, nullable: true }) + mobile: string; + + @Column({ type: 'varchar', length: 500, nullable: true }) + website: string; + + // Terminos de pago + @Column({ name: 'payment_term_days', type: 'int', default: 0 }) + paymentTermDays: number; + + @Column({ name: 'credit_limit', type: 'decimal', precision: 15, scale: 2, default: 0 }) + creditLimit: number; + + @Column({ name: 'current_balance', type: 'decimal', precision: 15, scale: 2, default: 0 }) + currentBalance: number; + + // Lista de precios + @Column({ name: 'price_list_id', type: 'uuid', nullable: true }) + priceListId: string; + + // Descuentos + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + // Categoria + @Column({ type: 'varchar', length: 50, nullable: true }) + category: string; + + @Column({ type: 'text', array: true, default: '{}' }) + tags: string[]; + + // Notas + @Column({ type: 'text', nullable: true }) + notes: string; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + // Vendedor asignado + @Column({ name: 'sales_rep_id', type: 'uuid', nullable: true }) + salesRepId: string; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/partners/index.ts b/src/modules/partners/index.ts new file mode 100644 index 0000000..df1c997 --- /dev/null +++ b/src/modules/partners/index.ts @@ -0,0 +1,5 @@ +export { PartnersModule, PartnersModuleOptions } from './partners.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; 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.module.ts b/src/modules/partners/partners.module.ts new file mode 100644 index 0000000..8e6e8c8 --- /dev/null +++ b/src/modules/partners/partners.module.ts @@ -0,0 +1,48 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { PartnersService } from './services'; +import { PartnersController } from './controllers'; +import { Partner, PartnerAddress, PartnerContact, PartnerBankAccount } from './entities'; + +export interface PartnersModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class PartnersModule { + public router: Router; + public partnersService: PartnersService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: PartnersModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const partnerRepository = this.dataSource.getRepository(Partner); + const addressRepository = this.dataSource.getRepository(PartnerAddress); + const contactRepository = this.dataSource.getRepository(PartnerContact); + const bankAccountRepository = this.dataSource.getRepository(PartnerBankAccount); + + this.partnersService = new PartnersService( + partnerRepository, + addressRepository, + contactRepository, + bankAccountRepository + ); + } + + private initializeRoutes(): void { + const partnersController = new PartnersController(this.partnersService); + this.router.use(`${this.basePath}/partners`, partnersController.router); + } + + static getEntities(): Function[] { + return [Partner, PartnerAddress, PartnerContact, PartnerBankAccount]; + } +} 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/partners/services/index.ts b/src/modules/partners/services/index.ts new file mode 100644 index 0000000..bd0ac0d --- /dev/null +++ b/src/modules/partners/services/index.ts @@ -0,0 +1 @@ +export { PartnersService, PartnerSearchParams } from './partners.service'; diff --git a/src/modules/partners/services/partners.service.ts b/src/modules/partners/services/partners.service.ts new file mode 100644 index 0000000..cac026d --- /dev/null +++ b/src/modules/partners/services/partners.service.ts @@ -0,0 +1,266 @@ +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { Partner, PartnerAddress, PartnerContact, PartnerBankAccount } from '../entities'; +import { + CreatePartnerDto, + UpdatePartnerDto, + CreatePartnerAddressDto, + CreatePartnerContactDto, + CreatePartnerBankAccountDto, +} from '../dto'; + +export interface PartnerSearchParams { + tenantId: string; + search?: string; + partnerType?: 'customer' | 'supplier' | 'both'; + category?: string; + isActive?: boolean; + salesRepId?: string; + limit?: number; + offset?: number; +} + +export class PartnersService { + constructor( + private readonly partnerRepository: Repository, + private readonly addressRepository: Repository, + private readonly contactRepository: Repository, + private readonly bankAccountRepository: Repository + ) {} + + // ==================== Partners ==================== + + async findAll(params: PartnerSearchParams): Promise<{ data: Partner[]; total: number }> { + const { + tenantId, + search, + partnerType, + category, + isActive, + salesRepId, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (partnerType) { + baseWhere.partnerType = partnerType; + } + + if (category) { + baseWhere.category = category; + } + + if (isActive !== undefined) { + baseWhere.isActive = isActive; + } + + if (salesRepId) { + baseWhere.salesRepId = salesRepId; + } + + if (search) { + where.push( + { ...baseWhere, displayName: ILike(`%${search}%`) }, + { ...baseWhere, legalName: ILike(`%${search}%`) }, + { ...baseWhere, code: ILike(`%${search}%`) }, + { ...baseWhere, taxId: ILike(`%${search}%`) }, + { ...baseWhere, email: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.partnerRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { displayName: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.partnerRepository.findOne({ where: { id, tenantId } }); + } + + async findByCode(code: string, tenantId: string): Promise { + return this.partnerRepository.findOne({ where: { code, tenantId } }); + } + + async findByTaxId(taxId: string, tenantId: string): Promise { + return this.partnerRepository.findOne({ where: { taxId, tenantId } }); + } + + async create(tenantId: string, dto: CreatePartnerDto, createdBy?: string): Promise { + // Check for existing code + const existingCode = await this.findByCode(dto.code, tenantId); + if (existingCode) { + throw new Error('A partner with this code already exists'); + } + + // Check for existing tax ID + if (dto.taxId) { + const existingTaxId = await this.findByTaxId(dto.taxId, tenantId); + if (existingTaxId) { + throw new Error('A partner with this tax ID already exists'); + } + } + + const partner = this.partnerRepository.create({ + ...dto, + tenantId, + createdBy, + }); + + return this.partnerRepository.save(partner); + } + + async update( + id: string, + tenantId: string, + dto: UpdatePartnerDto, + updatedBy?: string + ): Promise { + const partner = await this.findOne(id, tenantId); + if (!partner) return null; + + // If changing code, check for duplicates + if (dto.code && dto.code !== partner.code) { + const existing = await this.findByCode(dto.code, tenantId); + if (existing) { + throw new Error('A partner with this code already exists'); + } + } + + // If changing tax ID, check for duplicates + if (dto.taxId && dto.taxId !== partner.taxId) { + const existing = await this.findByTaxId(dto.taxId, tenantId); + if (existing && existing.id !== id) { + throw new Error('A partner with this tax ID already exists'); + } + } + + Object.assign(partner, { + ...dto, + updatedBy, + }); + + return this.partnerRepository.save(partner); + } + + async delete(id: string, tenantId: string): Promise { + const partner = await this.findOne(id, tenantId); + if (!partner) return false; + + const result = await this.partnerRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async getCustomers(tenantId: string): Promise { + return this.partnerRepository.find({ + where: [ + { tenantId, partnerType: 'customer', isActive: true }, + { tenantId, partnerType: 'both', isActive: true }, + ], + order: { displayName: 'ASC' }, + }); + } + + async getSuppliers(tenantId: string): Promise { + return this.partnerRepository.find({ + where: [ + { tenantId, partnerType: 'supplier', isActive: true }, + { tenantId, partnerType: 'both', isActive: true }, + ], + order: { displayName: 'ASC' }, + }); + } + + // ==================== Addresses ==================== + + async getAddresses(partnerId: string): Promise { + return this.addressRepository.find({ + where: { partnerId }, + order: { isDefault: 'DESC', addressType: 'ASC' }, + }); + } + + async createAddress(dto: CreatePartnerAddressDto): Promise { + // If setting as default, unset other defaults of same type + if (dto.isDefault) { + await this.addressRepository.update( + { partnerId: dto.partnerId, addressType: dto.addressType }, + { isDefault: false } + ); + } + + const address = this.addressRepository.create(dto); + return this.addressRepository.save(address); + } + + async deleteAddress(id: string): Promise { + const result = await this.addressRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + // ==================== Contacts ==================== + + async getContacts(partnerId: string): Promise { + return this.contactRepository.find({ + where: { partnerId }, + order: { isPrimary: 'DESC', fullName: 'ASC' }, + }); + } + + async createContact(dto: CreatePartnerContactDto): Promise { + // If setting as primary, unset other primaries + if (dto.isPrimary) { + await this.contactRepository.update({ partnerId: dto.partnerId }, { isPrimary: false }); + } + + const contact = this.contactRepository.create(dto); + return this.contactRepository.save(contact); + } + + async deleteContact(id: string): Promise { + const result = await this.contactRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + // ==================== Bank Accounts ==================== + + async getBankAccounts(partnerId: string): Promise { + return this.bankAccountRepository.find({ + where: { partnerId }, + order: { isDefault: 'DESC', bankName: 'ASC' }, + }); + } + + async createBankAccount(dto: CreatePartnerBankAccountDto): Promise { + // If setting as default, unset other defaults + if (dto.isDefault) { + await this.bankAccountRepository.update({ partnerId: dto.partnerId }, { isDefault: false }); + } + + const bankAccount = this.bankAccountRepository.create(dto); + return this.bankAccountRepository.save(bankAccount); + } + + async deleteBankAccount(id: string): Promise { + const result = await this.bankAccountRepository.delete(id); + return (result.affected ?? 0) > 0; + } + + async verifyBankAccount(id: string): Promise { + const bankAccount = await this.bankAccountRepository.findOne({ where: { id } }); + if (!bankAccount) return null; + + bankAccount.isVerified = true; + bankAccount.verifiedAt = new Date(); + + return this.bankAccountRepository.save(bankAccount); + } +} diff --git a/src/modules/payment-terminals/controllers/index.ts b/src/modules/payment-terminals/controllers/index.ts new file mode 100644 index 0000000..5aff8eb --- /dev/null +++ b/src/modules/payment-terminals/controllers/index.ts @@ -0,0 +1,6 @@ +/** + * Payment Terminals Controllers Index + */ + +export { TerminalsController } from './terminals.controller'; +export { TransactionsController } from './transactions.controller'; diff --git a/src/modules/payment-terminals/controllers/terminals.controller.ts b/src/modules/payment-terminals/controllers/terminals.controller.ts new file mode 100644 index 0000000..8749190 --- /dev/null +++ b/src/modules/payment-terminals/controllers/terminals.controller.ts @@ -0,0 +1,192 @@ +/** + * Terminals Controller + * + * REST API endpoints for terminal management + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { TerminalsService } from '../services'; +import { CreateTerminalDto, UpdateTerminalDto } from '../dto'; + +// Extend Request to include tenant info +interface AuthenticatedRequest extends Request { + tenantId?: string; + userId?: string; +} + +export class TerminalsController { + public router: Router; + private service: TerminalsService; + + constructor(dataSource: DataSource) { + this.router = Router(); + this.service = new TerminalsService(dataSource); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Branch terminals + this.router.get('/branch/:branchId', this.getByBranch.bind(this)); + this.router.get('/branch/:branchId/primary', this.getPrimary.bind(this)); + + // Terminal CRUD + this.router.get('/:id', this.getById.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.put('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + + // Terminal actions + this.router.get('/:id/health', this.checkHealth.bind(this)); + this.router.post('/:id/set-primary', this.setPrimary.bind(this)); + + // Health check batch (for scheduled job) + this.router.post('/health-check-batch', this.healthCheckBatch.bind(this)); + } + + /** + * GET /payment-terminals/branch/:branchId + * Get terminals for branch + */ + private async getByBranch(req: Request, res: Response, next: NextFunction): Promise { + try { + const terminals = await this.service.findByBranch(req.params.branchId); + res.json({ data: terminals }); + } catch (error) { + next(error); + } + } + + /** + * GET /payment-terminals/branch/:branchId/primary + * Get primary terminal for branch + */ + private async getPrimary(req: Request, res: Response, next: NextFunction): Promise { + try { + const terminal = await this.service.findPrimaryTerminal(req.params.branchId); + res.json({ data: terminal }); + } catch (error) { + next(error); + } + } + + /** + * GET /payment-terminals/:id + * Get terminal by ID + */ + private async getById(req: Request, res: Response, next: NextFunction): Promise { + try { + const terminal = await this.service.findById(req.params.id); + + if (!terminal) { + res.status(404).json({ error: 'Terminal not found' }); + return; + } + + res.json({ data: terminal }); + } catch (error) { + next(error); + } + } + + /** + * POST /payment-terminals + * Create new terminal + */ + private async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const dto: CreateTerminalDto = req.body; + const terminal = await this.service.create(req.tenantId!, dto); + res.status(201).json({ data: terminal }); + } catch (error) { + next(error); + } + } + + /** + * PUT /payment-terminals/:id + * Update terminal + */ + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: UpdateTerminalDto = req.body; + const terminal = await this.service.update(req.params.id, dto); + res.json({ data: terminal }); + } catch (error) { + next(error); + } + } + + /** + * DELETE /payment-terminals/:id + * Delete terminal (soft delete) + */ + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + await this.service.delete(req.params.id); + res.status(204).send(); + } catch (error) { + next(error); + } + } + + /** + * GET /payment-terminals/:id/health + * Check terminal health + */ + private async checkHealth(req: Request, res: Response, next: NextFunction): Promise { + try { + const health = await this.service.checkHealth(req.params.id); + res.json({ data: health }); + } catch (error) { + next(error); + } + } + + /** + * POST /payment-terminals/:id/set-primary + * Set terminal as primary for branch + */ + private async setPrimary(req: Request, res: Response, next: NextFunction): Promise { + try { + const terminal = await this.service.setPrimary(req.params.id); + res.json({ data: terminal }); + } catch (error) { + next(error); + } + } + + /** + * POST /payment-terminals/health-check-batch + * Run health check on all terminals needing check (scheduled job endpoint) + */ + private async healthCheckBatch(req: Request, res: Response, next: NextFunction): Promise { + try { + const maxAgeMinutes = parseInt(req.query.maxAgeMinutes as string) || 30; + const terminals = await this.service.findTerminalsNeedingHealthCheck(maxAgeMinutes); + + const results: { terminalId: string; status: string; message: string }[] = []; + + for (const terminal of terminals) { + try { + const health = await this.service.checkHealth(terminal.id); + results.push({ + terminalId: terminal.id, + status: health.status, + message: health.message, + }); + } catch (error: any) { + results.push({ + terminalId: terminal.id, + status: 'error', + message: error.message, + }); + } + } + + res.json({ data: { checked: results.length, results } }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/payment-terminals/controllers/transactions.controller.ts b/src/modules/payment-terminals/controllers/transactions.controller.ts new file mode 100644 index 0000000..7b736c0 --- /dev/null +++ b/src/modules/payment-terminals/controllers/transactions.controller.ts @@ -0,0 +1,163 @@ +/** + * Transactions Controller + * + * REST API endpoints for payment transactions + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { TransactionsService } from '../services'; +import { ProcessPaymentDto, ProcessRefundDto, SendReceiptDto, TransactionFilterDto } from '../dto'; + +// Extend Request to include tenant info +interface AuthenticatedRequest extends Request { + tenantId?: string; + userId?: string; +} + +export class TransactionsController { + public router: Router; + private service: TransactionsService; + + constructor(dataSource: DataSource) { + this.router = Router(); + this.service = new TransactionsService(dataSource); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Stats + this.router.get('/stats', this.getStats.bind(this)); + + // Payment processing + this.router.post('/charge', this.processPayment.bind(this)); + this.router.post('/refund', this.processRefund.bind(this)); + + // Transaction queries + this.router.get('/', this.getAll.bind(this)); + this.router.get('/:id', this.getById.bind(this)); + + // Actions + this.router.post('/:id/receipt', this.sendReceipt.bind(this)); + } + + /** + * GET /payment-transactions/stats + * Get transaction statistics + */ + private async getStats(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const filter: TransactionFilterDto = { + branchId: req.query.branchId as string, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + }; + + const stats = await this.service.getStats(req.tenantId!, filter); + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + /** + * POST /payment-transactions/charge + * Process a payment + */ + private async processPayment(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const dto: ProcessPaymentDto = req.body; + const result = await this.service.processPayment(req.tenantId!, req.userId!, dto); + + if (result.success) { + res.status(201).json({ data: result }); + } else { + res.status(400).json({ data: result }); + } + } catch (error) { + next(error); + } + } + + /** + * POST /payment-transactions/refund + * Process a refund + */ + private async processRefund(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const dto: ProcessRefundDto = req.body; + const result = await this.service.processRefund(req.tenantId!, req.userId!, dto); + + if (result.success) { + res.json({ data: result }); + } else { + res.status(400).json({ data: result }); + } + } catch (error) { + next(error); + } + } + + /** + * GET /payment-transactions + * Get transactions with filters + */ + private async getAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const filter: TransactionFilterDto = { + branchId: req.query.branchId as string, + userId: req.query.userId as string, + status: req.query.status as any, + sourceType: req.query.sourceType as any, + terminalProvider: req.query.terminalProvider as string, + startDate: req.query.startDate ? new Date(req.query.startDate as string) : undefined, + endDate: req.query.endDate ? new Date(req.query.endDate as string) : undefined, + limit: req.query.limit ? parseInt(req.query.limit as string) : undefined, + offset: req.query.offset ? parseInt(req.query.offset as string) : undefined, + }; + + const result = await this.service.findAll(req.tenantId!, filter); + res.json(result); + } catch (error) { + next(error); + } + } + + /** + * GET /payment-transactions/:id + * Get transaction by ID + */ + private async getById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const transaction = await this.service.findById(req.params.id, req.tenantId!); + + if (!transaction) { + res.status(404).json({ error: 'Transaction not found' }); + return; + } + + res.json({ data: transaction }); + } catch (error) { + next(error); + } + } + + /** + * POST /payment-transactions/:id/receipt + * Send receipt for transaction + */ + private async sendReceipt(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { + try { + const dto: SendReceiptDto = req.body; + const result = await this.service.sendReceipt(req.params.id, req.tenantId!, dto); + + if (result.success) { + res.json({ success: true }); + } else { + res.status(400).json({ success: false, error: result.error }); + } + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/payment-terminals/dto/index.ts b/src/modules/payment-terminals/dto/index.ts new file mode 100644 index 0000000..02c5c6e --- /dev/null +++ b/src/modules/payment-terminals/dto/index.ts @@ -0,0 +1,6 @@ +/** + * Payment Terminals DTOs Index + */ + +export * from './terminal.dto'; +export * from './transaction.dto'; diff --git a/src/modules/payment-terminals/dto/terminal.dto.ts b/src/modules/payment-terminals/dto/terminal.dto.ts new file mode 100644 index 0000000..00b8fca --- /dev/null +++ b/src/modules/payment-terminals/dto/terminal.dto.ts @@ -0,0 +1,47 @@ +/** + * Terminal DTOs + */ + +import { TerminalProvider, HealthStatus } from '../../branches/entities/branch-payment-terminal.entity'; + +export class CreateTerminalDto { + branchId: string; + terminalProvider: TerminalProvider; + terminalId: string; + terminalName?: string; + credentials?: Record; + isPrimary?: boolean; + dailyLimit?: number; + transactionLimit?: number; +} + +export class UpdateTerminalDto { + terminalName?: string; + credentials?: Record; + isPrimary?: boolean; + isActive?: boolean; + dailyLimit?: number; + transactionLimit?: number; +} + +export class TerminalHealthCheckDto { + terminalId: string; + status: HealthStatus; + message?: string; + responseTime?: number; +} + +export class TerminalResponseDto { + id: string; + branchId: string; + terminalProvider: TerminalProvider; + terminalId: string; + terminalName?: string; + isPrimary: boolean; + isActive: boolean; + dailyLimit?: number; + transactionLimit?: number; + healthStatus: HealthStatus; + lastTransactionAt?: Date; + lastHealthCheckAt?: Date; +} diff --git a/src/modules/payment-terminals/dto/transaction.dto.ts b/src/modules/payment-terminals/dto/transaction.dto.ts new file mode 100644 index 0000000..0a1bfe5 --- /dev/null +++ b/src/modules/payment-terminals/dto/transaction.dto.ts @@ -0,0 +1,75 @@ +/** + * Transaction DTOs + */ + +import { PaymentSourceType, PaymentMethod, PaymentStatus } from '../../mobile/entities/payment-transaction.entity'; + +export class ProcessPaymentDto { + terminalId: string; + amount: number; + currency?: string; + tipAmount?: number; + sourceType: PaymentSourceType; + sourceId: string; + description?: string; + customerEmail?: string; + customerPhone?: string; +} + +export class PaymentResultDto { + success: boolean; + transactionId?: string; + externalTransactionId?: string; + amount: number; + totalAmount: number; + tipAmount: number; + currency: string; + status: PaymentStatus; + paymentMethod?: PaymentMethod; + cardBrand?: string; + cardLastFour?: string; + receiptUrl?: string; + error?: string; + errorCode?: string; +} + +export class ProcessRefundDto { + transactionId: string; + amount?: number; // Partial refund if provided + reason?: string; +} + +export class RefundResultDto { + success: boolean; + refundId?: string; + amount: number; + status: 'pending' | 'completed' | 'failed'; + error?: string; +} + +export class SendReceiptDto { + email?: string; + phone?: string; +} + +export class TransactionFilterDto { + branchId?: string; + userId?: string; + status?: PaymentStatus; + startDate?: Date; + endDate?: Date; + sourceType?: PaymentSourceType; + terminalProvider?: string; + limit?: number; + offset?: number; +} + +export class TransactionStatsDto { + total: number; + totalAmount: number; + byStatus: Record; + byProvider: Record; + byPaymentMethod: Record; + averageAmount: number; + successRate: number; +} diff --git a/src/modules/payment-terminals/index.ts b/src/modules/payment-terminals/index.ts new file mode 100644 index 0000000..6794513 --- /dev/null +++ b/src/modules/payment-terminals/index.ts @@ -0,0 +1,15 @@ +/** + * Payment Terminals Module Index + */ + +// Module +export { PaymentTerminalsModule, PaymentTerminalsModuleOptions } from './payment-terminals.module'; + +// DTOs +export * from './dto'; + +// Services +export * from './services'; + +// Controllers +export * from './controllers'; diff --git a/src/modules/payment-terminals/payment-terminals.module.ts b/src/modules/payment-terminals/payment-terminals.module.ts new file mode 100644 index 0000000..b807407 --- /dev/null +++ b/src/modules/payment-terminals/payment-terminals.module.ts @@ -0,0 +1,46 @@ +/** + * Payment Terminals Module + * + * Module registration for payment terminals and transactions + */ + +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { TerminalsController, TransactionsController } from './controllers'; + +export interface PaymentTerminalsModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class PaymentTerminalsModule { + public router: Router; + private terminalsController: TerminalsController; + private transactionsController: TransactionsController; + + constructor(options: PaymentTerminalsModuleOptions) { + const { dataSource, basePath = '' } = options; + + this.router = Router(); + + // Initialize controllers + this.terminalsController = new TerminalsController(dataSource); + this.transactionsController = new TransactionsController(dataSource); + + // Register routes + this.router.use(`${basePath}/payment-terminals`, this.terminalsController.router); + this.router.use(`${basePath}/payment-transactions`, this.transactionsController.router); + } + + /** + * Get all entities for this module (for TypeORM configuration) + */ + static getEntities() { + return [ + require('../branches/entities/branch-payment-terminal.entity').BranchPaymentTerminal, + require('../mobile/entities/payment-transaction.entity').PaymentTransaction, + ]; + } +} + +export default PaymentTerminalsModule; diff --git a/src/modules/payment-terminals/services/index.ts b/src/modules/payment-terminals/services/index.ts new file mode 100644 index 0000000..b15f539 --- /dev/null +++ b/src/modules/payment-terminals/services/index.ts @@ -0,0 +1,6 @@ +/** + * Payment Terminals Services Index + */ + +export { TerminalsService } from './terminals.service'; +export { TransactionsService } from './transactions.service'; diff --git a/src/modules/payment-terminals/services/terminals.service.ts b/src/modules/payment-terminals/services/terminals.service.ts new file mode 100644 index 0000000..16ed00e --- /dev/null +++ b/src/modules/payment-terminals/services/terminals.service.ts @@ -0,0 +1,224 @@ +/** + * Terminals Service + * + * Service for managing payment terminals + */ + +import { Repository, DataSource } from 'typeorm'; +import { BranchPaymentTerminal, HealthStatus } from '../../branches/entities/branch-payment-terminal.entity'; +import { CreateTerminalDto, UpdateTerminalDto, TerminalResponseDto } from '../dto'; + +export class TerminalsService { + private terminalRepository: Repository; + + constructor(private dataSource: DataSource) { + this.terminalRepository = dataSource.getRepository(BranchPaymentTerminal); + } + + /** + * Create a new terminal + */ + async create(tenantId: string, dto: CreateTerminalDto): Promise { + // If setting as primary, unset other primary terminals for this branch + if (dto.isPrimary) { + await this.terminalRepository.update( + { branchId: dto.branchId, isPrimary: true }, + { isPrimary: false } + ); + } + + const terminal = this.terminalRepository.create({ + branchId: dto.branchId, + terminalProvider: dto.terminalProvider, + terminalId: dto.terminalId, + terminalName: dto.terminalName, + credentials: dto.credentials || {}, + isPrimary: dto.isPrimary || false, + dailyLimit: dto.dailyLimit, + transactionLimit: dto.transactionLimit, + isActive: true, + healthStatus: 'unknown', + }); + + return this.terminalRepository.save(terminal); + } + + /** + * Find terminals by branch + */ + async findByBranch(branchId: string): Promise { + const terminals = await this.terminalRepository.find({ + where: { branchId, isActive: true }, + order: { isPrimary: 'DESC', createdAt: 'ASC' }, + }); + + return terminals.map(this.toResponseDto); + } + + /** + * Find primary terminal for branch + */ + async findPrimaryTerminal(branchId: string): Promise { + return this.terminalRepository.findOne({ + where: { branchId, isPrimary: true, isActive: true }, + }); + } + + /** + * Find terminal by ID + */ + async findById(id: string): Promise { + return this.terminalRepository.findOne({ where: { id } }); + } + + /** + * Update terminal + */ + async update(id: string, dto: UpdateTerminalDto): Promise { + const terminal = await this.findById(id); + if (!terminal) { + throw new Error('Terminal not found'); + } + + // If setting as primary, unset other primary terminals for this branch + if (dto.isPrimary && !terminal.isPrimary) { + await this.terminalRepository.update( + { branchId: terminal.branchId, isPrimary: true }, + { isPrimary: false } + ); + } + + Object.assign(terminal, dto); + return this.terminalRepository.save(terminal); + } + + /** + * Delete terminal (soft delete by deactivating) + */ + async delete(id: string): Promise { + const terminal = await this.findById(id); + if (!terminal) { + throw new Error('Terminal not found'); + } + + terminal.isActive = false; + await this.terminalRepository.save(terminal); + } + + /** + * Set terminal as primary + */ + async setPrimary(id: string): Promise { + const terminal = await this.findById(id); + if (!terminal) { + throw new Error('Terminal not found'); + } + + // Unset other primary terminals + await this.terminalRepository.update( + { branchId: terminal.branchId, isPrimary: true }, + { isPrimary: false } + ); + + terminal.isPrimary = true; + return this.terminalRepository.save(terminal); + } + + /** + * Check terminal health + */ + async checkHealth(id: string): Promise<{ status: HealthStatus; message: string }> { + const terminal = await this.findById(id); + if (!terminal) { + throw new Error('Terminal not found'); + } + + // Simulate health check based on provider + // In production, this would make an actual API call to the provider + let status: HealthStatus = 'healthy'; + let message = 'Terminal is operational'; + + try { + // Simulate provider health check + switch (terminal.terminalProvider) { + case 'clip': + // Check Clip terminal status + break; + case 'mercadopago': + // Check MercadoPago terminal status + break; + case 'stripe': + // Check Stripe terminal status + break; + } + } catch (error: any) { + status = 'offline'; + message = error.message || 'Failed to connect to terminal'; + } + + // Update terminal health status + terminal.healthStatus = status; + terminal.lastHealthCheckAt = new Date(); + await this.terminalRepository.save(terminal); + + return { status, message }; + } + + /** + * Update health status (called after transactions) + */ + async updateHealthStatus(id: string, status: HealthStatus): Promise { + await this.terminalRepository.update(id, { + healthStatus: status, + lastHealthCheckAt: new Date(), + }); + } + + /** + * Update last transaction timestamp + */ + async updateLastTransaction(id: string): Promise { + await this.terminalRepository.update(id, { + lastTransactionAt: new Date(), + healthStatus: 'healthy', // If transaction works, terminal is healthy + lastHealthCheckAt: new Date(), + }); + } + + /** + * Find terminals needing health check + */ + async findTerminalsNeedingHealthCheck(maxAgeMinutes: number = 30): Promise { + const threshold = new Date(); + threshold.setMinutes(threshold.getMinutes() - maxAgeMinutes); + + return this.terminalRepository + .createQueryBuilder('terminal') + .where('terminal.isActive = true') + .andWhere( + '(terminal.lastHealthCheckAt IS NULL OR terminal.lastHealthCheckAt < :threshold)', + { threshold } + ) + .getMany(); + } + + /** + * Convert entity to response DTO (without credentials) + */ + private toResponseDto(terminal: BranchPaymentTerminal): TerminalResponseDto { + return { + id: terminal.id, + branchId: terminal.branchId, + terminalProvider: terminal.terminalProvider, + terminalId: terminal.terminalId, + terminalName: terminal.terminalName, + isPrimary: terminal.isPrimary, + isActive: terminal.isActive, + dailyLimit: terminal.dailyLimit ? Number(terminal.dailyLimit) : undefined, + transactionLimit: terminal.transactionLimit ? Number(terminal.transactionLimit) : undefined, + healthStatus: terminal.healthStatus, + lastTransactionAt: terminal.lastTransactionAt, + lastHealthCheckAt: terminal.lastHealthCheckAt, + }; + } +} diff --git a/src/modules/payment-terminals/services/transactions.service.ts b/src/modules/payment-terminals/services/transactions.service.ts new file mode 100644 index 0000000..ed574d0 --- /dev/null +++ b/src/modules/payment-terminals/services/transactions.service.ts @@ -0,0 +1,498 @@ +/** + * Transactions Service + * + * Service for processing and managing payment transactions + */ + +import { Repository, DataSource, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm'; +import { + PaymentTransaction, + PaymentStatus, + PaymentMethod, +} from '../../mobile/entities/payment-transaction.entity'; +import { BranchPaymentTerminal } from '../../branches/entities/branch-payment-terminal.entity'; +import { + ProcessPaymentDto, + PaymentResultDto, + ProcessRefundDto, + RefundResultDto, + SendReceiptDto, + TransactionFilterDto, + TransactionStatsDto, +} from '../dto'; +import { CircuitBreaker } from '../../../shared/utils/circuit-breaker'; + +export class TransactionsService { + private transactionRepository: Repository; + private terminalRepository: Repository; + private circuitBreakers: Map = new Map(); + + constructor(private dataSource: DataSource) { + this.transactionRepository = dataSource.getRepository(PaymentTransaction); + this.terminalRepository = dataSource.getRepository(BranchPaymentTerminal); + } + + /** + * Process a payment + */ + async processPayment( + tenantId: string, + userId: string, + dto: ProcessPaymentDto + ): Promise { + // Get terminal + const terminal = await this.terminalRepository.findOne({ + where: { id: dto.terminalId, isActive: true }, + }); + + if (!terminal) { + return this.errorResult(dto.amount, dto.tipAmount || 0, 'Terminal not found', 'TERMINAL_NOT_FOUND'); + } + + // Check transaction limit + if (terminal.transactionLimit && dto.amount > Number(terminal.transactionLimit)) { + return this.errorResult( + dto.amount, + dto.tipAmount || 0, + `Amount exceeds transaction limit of ${terminal.transactionLimit}`, + 'LIMIT_EXCEEDED' + ); + } + + // Get or create circuit breaker for this terminal + const circuitBreaker = this.getCircuitBreaker(terminal.id); + + // Create transaction record + const transaction = this.transactionRepository.create({ + tenantId, + branchId: terminal.branchId, + userId, + sourceType: dto.sourceType, + sourceId: dto.sourceId, + terminalProvider: terminal.terminalProvider, + terminalId: terminal.terminalId, + amount: dto.amount, + currency: dto.currency || 'MXN', + tipAmount: dto.tipAmount || 0, + totalAmount: dto.amount + (dto.tipAmount || 0), + paymentMethod: 'card', // Default, will be updated from provider response + status: 'pending', + initiatedAt: new Date(), + }); + + await this.transactionRepository.save(transaction); + + try { + // Process through circuit breaker + const providerResult = await circuitBreaker.execute(async () => { + return this.processWithProvider(terminal, transaction, dto); + }); + + // Update transaction with result + transaction.status = providerResult.status; + transaction.externalTransactionId = providerResult.externalTransactionId; + transaction.paymentMethod = providerResult.paymentMethod; + transaction.cardBrand = providerResult.cardBrand; + transaction.cardLastFour = providerResult.cardLastFour; + transaction.receiptUrl = providerResult.receiptUrl; + transaction.providerResponse = providerResult.rawResponse; + + if (providerResult.status === 'completed') { + transaction.completedAt = new Date(); + // Update terminal last transaction + await this.terminalRepository.update(terminal.id, { + lastTransactionAt: new Date(), + healthStatus: 'healthy', + }); + } else if (providerResult.status === 'failed') { + transaction.failureReason = providerResult.error; + } + + await this.transactionRepository.save(transaction); + + return { + success: providerResult.status === 'completed', + transactionId: transaction.id, + externalTransactionId: providerResult.externalTransactionId, + amount: dto.amount, + totalAmount: transaction.totalAmount, + tipAmount: transaction.tipAmount, + currency: transaction.currency, + status: transaction.status, + paymentMethod: transaction.paymentMethod, + cardBrand: transaction.cardBrand, + cardLastFour: transaction.cardLastFour, + receiptUrl: transaction.receiptUrl, + error: providerResult.error, + }; + } catch (error: any) { + // Circuit breaker opened or other error + transaction.status = 'failed'; + transaction.failureReason = error.message; + await this.transactionRepository.save(transaction); + + // Update terminal health + await this.terminalRepository.update(terminal.id, { + healthStatus: 'offline', + lastHealthCheckAt: new Date(), + }); + + return this.errorResult( + dto.amount, + dto.tipAmount || 0, + error.message, + 'PROVIDER_ERROR', + transaction.id + ); + } + } + + /** + * Process refund + */ + async processRefund( + tenantId: string, + userId: string, + dto: ProcessRefundDto + ): Promise { + const transaction = await this.transactionRepository.findOne({ + where: { id: dto.transactionId, tenantId }, + }); + + if (!transaction) { + return { success: false, amount: 0, status: 'failed', error: 'Transaction not found' }; + } + + if (transaction.status !== 'completed') { + return { + success: false, + amount: 0, + status: 'failed', + error: 'Only completed transactions can be refunded', + }; + } + + const refundAmount = dto.amount || Number(transaction.totalAmount); + + if (refundAmount > Number(transaction.totalAmount)) { + return { + success: false, + amount: 0, + status: 'failed', + error: 'Refund amount cannot exceed transaction amount', + }; + } + + try { + // Get terminal for provider info + const terminal = await this.terminalRepository.findOne({ + where: { terminalProvider: transaction.terminalProvider as any }, + }); + + // Process refund with provider + // In production, this would call the actual provider API + const refundResult = await this.processRefundWithProvider(transaction, refundAmount, dto.reason); + + if (refundResult.success) { + transaction.status = 'refunded'; + await this.transactionRepository.save(transaction); + } + + return { + success: refundResult.success, + refundId: refundResult.refundId, + amount: refundAmount, + status: refundResult.success ? 'completed' : 'failed', + error: refundResult.error, + }; + } catch (error: any) { + return { + success: false, + amount: refundAmount, + status: 'failed', + error: error.message, + }; + } + } + + /** + * Get transaction by ID + */ + async findById(id: string, tenantId: string): Promise { + return this.transactionRepository.findOne({ + where: { id, tenantId }, + }); + } + + /** + * Get transactions with filters + */ + async findAll( + tenantId: string, + filter: TransactionFilterDto + ): Promise<{ data: PaymentTransaction[]; total: number }> { + const query = this.transactionRepository + .createQueryBuilder('tx') + .where('tx.tenantId = :tenantId', { tenantId }); + + if (filter.branchId) { + query.andWhere('tx.branchId = :branchId', { branchId: filter.branchId }); + } + + if (filter.userId) { + query.andWhere('tx.userId = :userId', { userId: filter.userId }); + } + + if (filter.status) { + query.andWhere('tx.status = :status', { status: filter.status }); + } + + if (filter.sourceType) { + query.andWhere('tx.sourceType = :sourceType', { sourceType: filter.sourceType }); + } + + if (filter.terminalProvider) { + query.andWhere('tx.terminalProvider = :provider', { provider: filter.terminalProvider }); + } + + if (filter.startDate) { + query.andWhere('tx.createdAt >= :startDate', { startDate: filter.startDate }); + } + + if (filter.endDate) { + query.andWhere('tx.createdAt <= :endDate', { endDate: filter.endDate }); + } + + const total = await query.getCount(); + + query.orderBy('tx.createdAt', 'DESC'); + + if (filter.limit) { + query.take(filter.limit); + } + + if (filter.offset) { + query.skip(filter.offset); + } + + const data = await query.getMany(); + + return { data, total }; + } + + /** + * Send receipt + */ + async sendReceipt( + transactionId: string, + tenantId: string, + dto: SendReceiptDto + ): Promise<{ success: boolean; error?: string }> { + const transaction = await this.findById(transactionId, tenantId); + if (!transaction) { + return { success: false, error: 'Transaction not found' }; + } + + if (!dto.email && !dto.phone) { + return { success: false, error: 'Email or phone is required' }; + } + + try { + // Send receipt via email or SMS + // In production, this would integrate with email/SMS service + + transaction.receiptSent = true; + transaction.receiptSentTo = dto.email || dto.phone || ''; + await this.transactionRepository.save(transaction); + + return { success: true }; + } catch (error: any) { + return { success: false, error: error.message }; + } + } + + /** + * Get transaction statistics + */ + async getStats(tenantId: string, filter?: TransactionFilterDto): Promise { + const query = this.transactionRepository + .createQueryBuilder('tx') + .where('tx.tenantId = :tenantId', { tenantId }); + + if (filter?.branchId) { + query.andWhere('tx.branchId = :branchId', { branchId: filter.branchId }); + } + + if (filter?.startDate) { + query.andWhere('tx.createdAt >= :startDate', { startDate: filter.startDate }); + } + + if (filter?.endDate) { + query.andWhere('tx.createdAt <= :endDate', { endDate: filter.endDate }); + } + + const transactions = await query.getMany(); + + const byStatus: Record = { + pending: 0, + processing: 0, + completed: 0, + failed: 0, + refunded: 0, + cancelled: 0, + }; + + const byProvider: Record = {}; + const byPaymentMethod: Record = { + card: 0, + contactless: 0, + qr: 0, + link: 0, + }; + + let totalAmount = 0; + let completedCount = 0; + + for (const tx of transactions) { + byStatus[tx.status]++; + + if (!byProvider[tx.terminalProvider]) { + byProvider[tx.terminalProvider] = { count: 0, amount: 0 }; + } + byProvider[tx.terminalProvider].count++; + + if (tx.status === 'completed') { + totalAmount += Number(tx.totalAmount); + completedCount++; + byProvider[tx.terminalProvider].amount += Number(tx.totalAmount); + byPaymentMethod[tx.paymentMethod]++; + } + } + + const total = transactions.length; + const failedCount = byStatus.failed; + + return { + total, + totalAmount, + byStatus, + byProvider, + byPaymentMethod, + averageAmount: completedCount > 0 ? totalAmount / completedCount : 0, + successRate: total > 0 ? ((total - failedCount) / total) * 100 : 0, + }; + } + + /** + * Get or create circuit breaker for terminal + */ + private getCircuitBreaker(terminalId: string): CircuitBreaker { + if (!this.circuitBreakers.has(terminalId)) { + this.circuitBreakers.set( + terminalId, + new CircuitBreaker({ + name: `terminal-${terminalId}`, + failureThreshold: 3, + successThreshold: 2, + resetTimeout: 30000, // 30 seconds + }) + ); + } + return this.circuitBreakers.get(terminalId)!; + } + + /** + * Process payment with provider (simulated) + */ + private async processWithProvider( + terminal: BranchPaymentTerminal, + transaction: PaymentTransaction, + dto: ProcessPaymentDto + ): Promise<{ + status: PaymentStatus; + externalTransactionId?: string; + paymentMethod?: PaymentMethod; + cardBrand?: string; + cardLastFour?: string; + receiptUrl?: string; + rawResponse?: Record; + error?: string; + }> { + // In production, this would call the actual provider API + // For now, simulate a successful transaction + + // Simulate processing time + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Simulate success rate (95%) + const success = Math.random() > 0.05; + + if (success) { + return { + status: 'completed', + externalTransactionId: `${terminal.terminalProvider}-${Date.now()}`, + paymentMethod: 'card', + cardBrand: 'visa', + cardLastFour: '4242', + receiptUrl: `https://receipts.example.com/${transaction.id}`, + rawResponse: { + provider: terminal.terminalProvider, + approved: true, + timestamp: new Date().toISOString(), + }, + }; + } else { + return { + status: 'failed', + error: 'Payment declined by issuer', + rawResponse: { + provider: terminal.terminalProvider, + approved: false, + declineReason: 'insufficient_funds', + }, + }; + } + } + + /** + * Process refund with provider (simulated) + */ + private async processRefundWithProvider( + transaction: PaymentTransaction, + amount: number, + reason?: string + ): Promise<{ success: boolean; refundId?: string; error?: string }> { + // In production, this would call the actual provider API + + // Simulate processing + await new Promise((resolve) => setTimeout(resolve, 300)); + + return { + success: true, + refundId: `ref-${Date.now()}`, + }; + } + + /** + * Create error result + */ + private errorResult( + amount: number, + tipAmount: number, + error: string, + errorCode: string, + transactionId?: string + ): PaymentResultDto { + return { + success: false, + transactionId, + amount, + totalAmount: amount + tipAmount, + tipAmount, + currency: 'MXN', + status: 'failed', + error, + errorCode, + }; + } +} diff --git a/src/modules/products/controllers/index.ts b/src/modules/products/controllers/index.ts new file mode 100644 index 0000000..7e3e542 --- /dev/null +++ b/src/modules/products/controllers/index.ts @@ -0,0 +1 @@ +export { ProductsController, CategoriesController } from './products.controller'; diff --git a/src/modules/products/controllers/products.controller.ts b/src/modules/products/controllers/products.controller.ts new file mode 100644 index 0000000..770cd5c --- /dev/null +++ b/src/modules/products/controllers/products.controller.ts @@ -0,0 +1,377 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { ProductsService } from '../services/products.service'; +import { + CreateProductDto, + UpdateProductDto, + CreateProductCategoryDto, + UpdateProductCategoryDto, +} from '../dto'; + +export class ProductsController { + public router: Router; + + constructor(private readonly productsService: ProductsService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Products + this.router.get('/', this.findAll.bind(this)); + this.router.get('/sellable', this.getSellableProducts.bind(this)); + this.router.get('/purchasable', this.getPurchasableProducts.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.get('/sku/:sku', this.findBySku.bind(this)); + this.router.get('/barcode/:barcode', this.findByBarcode.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + } + + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { + search, + categoryId, + productType, + isActive, + isSellable, + isPurchasable, + limit, + offset, + } = req.query; + + const result = await this.productsService.findAll({ + tenantId, + search: search as string, + categoryId: categoryId as string, + productType: productType as 'product' | 'service' | 'consumable' | 'kit', + isActive: isActive ? isActive === 'true' : undefined, + isSellable: isSellable ? isSellable === 'true' : undefined, + isPurchasable: isPurchasable ? isPurchasable === 'true' : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const product = await this.productsService.findOne(id, tenantId); + + if (!product) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + res.json({ data: product }); + } catch (error) { + next(error); + } + } + + private async findBySku(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { sku } = req.params; + const product = await this.productsService.findBySku(sku, tenantId); + + if (!product) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + res.json({ data: product }); + } catch (error) { + next(error); + } + } + + private async findByBarcode(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { barcode } = req.params; + const product = await this.productsService.findByBarcode(barcode, tenantId); + + if (!product) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + res.json({ data: product }); + } catch (error) { + next(error); + } + } + + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: CreateProductDto = req.body; + const product = await this.productsService.create(tenantId, dto, userId); + res.status(201).json({ data: product }); + } catch (error) { + next(error); + } + } + + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const dto: UpdateProductDto = req.body; + const product = await this.productsService.update(id, tenantId, dto, userId); + + if (!product) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + res.json({ data: product }); + } catch (error) { + next(error); + } + } + + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const deleted = await this.productsService.delete(id, tenantId); + + if (!deleted) { + res.status(404).json({ error: 'Product not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async getSellableProducts(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const products = await this.productsService.getSellableProducts(tenantId); + res.json({ data: products }); + } catch (error) { + next(error); + } + } + + private async getPurchasableProducts( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const products = await this.productsService.getPurchasableProducts(tenantId); + res.json({ data: products }); + } catch (error) { + next(error); + } + } +} + +export class CategoriesController { + public router: Router; + + constructor(private readonly productsService: ProductsService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + this.router.get('/', this.findAll.bind(this)); + this.router.get('/tree', this.getCategoryTree.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + } + + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { search, parentId, isActive, limit, offset } = req.query; + + const result = await this.productsService.findAllCategories({ + tenantId, + search: search as string, + parentId: parentId as string, + isActive: isActive ? isActive === 'true' : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const category = await this.productsService.findCategory(id, tenantId); + + if (!category) { + res.status(404).json({ error: 'Category not found' }); + return; + } + + res.json({ data: category }); + } catch (error) { + next(error); + } + } + + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: CreateProductCategoryDto = req.body; + const category = await this.productsService.createCategory(tenantId, dto); + res.status(201).json({ data: category }); + } catch (error) { + next(error); + } + } + + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const dto: UpdateProductCategoryDto = req.body; + const category = await this.productsService.updateCategory(id, tenantId, dto); + + if (!category) { + res.status(404).json({ error: 'Category not found' }); + return; + } + + res.json({ data: category }); + } catch (error) { + next(error); + } + } + + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const deleted = await this.productsService.deleteCategory(id, tenantId); + + if (!deleted) { + res.status(404).json({ error: 'Category not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async getCategoryTree(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const categories = await this.productsService.getCategoryTree(tenantId); + res.json({ data: categories }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/products/dto/create-product.dto.ts b/src/modules/products/dto/create-product.dto.ts new file mode 100644 index 0000000..b398408 --- /dev/null +++ b/src/modules/products/dto/create-product.dto.ts @@ -0,0 +1,431 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsUUID, + MaxLength, + IsEnum, + Min, +} from 'class-validator'; + +export class CreateProductCategoryDto { + @IsString() + @MaxLength(20) + code: string; + + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + imageUrl?: string; + + @IsOptional() + @IsNumber() + sortOrder?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateProductCategoryDto { + @IsOptional() + @IsString() + @MaxLength(20) + code?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + imageUrl?: string; + + @IsOptional() + @IsNumber() + sortOrder?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class CreateProductDto { + @IsString() + @MaxLength(50) + sku: string; + + @IsOptional() + @IsString() + @MaxLength(50) + barcode?: string; + + @IsString() + @MaxLength(200) + name: string; + + @IsOptional() + @IsString() + @MaxLength(50) + shortName?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsEnum(['product', 'service', 'consumable', 'kit']) + productType?: 'product' | 'service' | 'consumable' | 'kit'; + + @IsOptional() + @IsNumber() + @Min(0) + salePrice?: number; + + @IsOptional() + @IsNumber() + @Min(0) + costPrice?: number; + + @IsOptional() + @IsNumber() + @Min(0) + minSalePrice?: number; + + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + @IsOptional() + @IsNumber() + taxRate?: number; + + @IsOptional() + @IsBoolean() + taxIncluded?: boolean; + + @IsOptional() + @IsString() + @MaxLength(20) + satProductCode?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + satUnitCode?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + uom?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + uomPurchase?: string; + + @IsOptional() + @IsNumber() + conversionFactor?: number; + + @IsOptional() + @IsBoolean() + trackInventory?: boolean; + + @IsOptional() + @IsNumber() + @Min(0) + minStock?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxStock?: number; + + @IsOptional() + @IsNumber() + @Min(0) + reorderPoint?: number; + + @IsOptional() + @IsNumber() + @Min(0) + reorderQuantity?: number; + + @IsOptional() + @IsBoolean() + trackLots?: boolean; + + @IsOptional() + @IsBoolean() + trackSerials?: boolean; + + @IsOptional() + @IsBoolean() + trackExpiry?: boolean; + + @IsOptional() + @IsNumber() + weight?: number; + + @IsOptional() + @IsString() + @MaxLength(10) + weightUnit?: string; + + @IsOptional() + @IsNumber() + length?: number; + + @IsOptional() + @IsNumber() + width?: number; + + @IsOptional() + @IsNumber() + height?: number; + + @IsOptional() + @IsString() + @MaxLength(10) + dimensionUnit?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + imageUrl?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + images?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isSellable?: boolean; + + @IsOptional() + @IsBoolean() + isPurchasable?: boolean; +} + +export class UpdateProductDto { + @IsOptional() + @IsString() + @MaxLength(50) + sku?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + barcode?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + shortName?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsEnum(['product', 'service', 'consumable', 'kit']) + productType?: 'product' | 'service' | 'consumable' | 'kit'; + + @IsOptional() + @IsNumber() + @Min(0) + salePrice?: number; + + @IsOptional() + @IsNumber() + @Min(0) + costPrice?: number; + + @IsOptional() + @IsNumber() + @Min(0) + minSalePrice?: number; + + @IsOptional() + @IsString() + @MaxLength(3) + currency?: string; + + @IsOptional() + @IsNumber() + taxRate?: number; + + @IsOptional() + @IsBoolean() + taxIncluded?: boolean; + + @IsOptional() + @IsString() + @MaxLength(20) + satProductCode?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + satUnitCode?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + uom?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + uomPurchase?: string; + + @IsOptional() + @IsNumber() + conversionFactor?: number; + + @IsOptional() + @IsBoolean() + trackInventory?: boolean; + + @IsOptional() + @IsNumber() + @Min(0) + minStock?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxStock?: number; + + @IsOptional() + @IsNumber() + @Min(0) + reorderPoint?: number; + + @IsOptional() + @IsNumber() + @Min(0) + reorderQuantity?: number; + + @IsOptional() + @IsBoolean() + trackLots?: boolean; + + @IsOptional() + @IsBoolean() + trackSerials?: boolean; + + @IsOptional() + @IsBoolean() + trackExpiry?: boolean; + + @IsOptional() + @IsNumber() + weight?: number; + + @IsOptional() + @IsString() + @MaxLength(10) + weightUnit?: string; + + @IsOptional() + @IsNumber() + length?: number; + + @IsOptional() + @IsNumber() + width?: number; + + @IsOptional() + @IsNumber() + height?: number; + + @IsOptional() + @IsString() + @MaxLength(10) + dimensionUnit?: string; + + @IsOptional() + @IsString() + @MaxLength(500) + imageUrl?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + images?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isSellable?: boolean; + + @IsOptional() + @IsBoolean() + isPurchasable?: boolean; +} diff --git a/src/modules/products/dto/index.ts b/src/modules/products/dto/index.ts new file mode 100644 index 0000000..94bf432 --- /dev/null +++ b/src/modules/products/dto/index.ts @@ -0,0 +1,6 @@ +export { + CreateProductCategoryDto, + UpdateProductCategoryDto, + CreateProductDto, + UpdateProductDto, +} from './create-product.dto'; diff --git a/src/modules/products/entities/index.ts b/src/modules/products/entities/index.ts new file mode 100644 index 0000000..1471528 --- /dev/null +++ b/src/modules/products/entities/index.ts @@ -0,0 +1,4 @@ +export { ProductCategory } from './product-category.entity'; +export { Product } from './product.entity'; +export { ProductPrice } from './product-price.entity'; +export { ProductSupplier } from './product-supplier.entity'; diff --git a/src/modules/products/entities/product-category.entity.ts b/src/modules/products/entities/product-category.entity.ts new file mode 100644 index 0000000..4de6df7 --- /dev/null +++ b/src/modules/products/entities/product-category.entity.ts @@ -0,0 +1,69 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; + +@Entity({ name: 'product_categories', schema: 'products' }) +export class ProductCategory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string; + + @ManyToOne(() => ProductCategory, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'parent_id' }) + parent: ProductCategory; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Jerarquia + @Column({ name: 'hierarchy_path', type: 'text', nullable: true }) + hierarchyPath: string; + + @Column({ name: 'hierarchy_level', type: 'int', default: 0 }) + hierarchyLevel: number; + + // Imagen + @Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) + imageUrl: string; + + // Orden + @Column({ name: 'sort_order', type: 'int', default: 0 }) + sortOrder: number; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/products/entities/product-price.entity.ts b/src/modules/products/entities/product-price.entity.ts new file mode 100644 index 0000000..c768e2b --- /dev/null +++ b/src/modules/products/entities/product-price.entity.ts @@ -0,0 +1,48 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Product } from './product.entity'; + +@Entity({ name: 'product_prices', schema: 'products' }) +export class ProductPrice { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @ManyToOne(() => Product, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'product_id' }) + product: Product; + + @Index() + @Column({ name: 'price_type', type: 'varchar', length: 30, default: 'standard' }) + priceType: 'standard' | 'wholesale' | 'retail' | 'promo'; + + @Column({ name: 'price_list_name', type: 'varchar', length: 100, nullable: true }) + priceListName?: string; + + @Column({ type: 'decimal', precision: 15, scale: 4 }) + price: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'min_quantity', type: 'decimal', precision: 15, scale: 4, default: 1 }) + minQuantity: number; + + @Column({ name: 'valid_from', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + validFrom: Date; + + @Column({ name: 'valid_to', type: 'timestamptz', nullable: true }) + validTo?: Date; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/products/entities/product-supplier.entity.ts b/src/modules/products/entities/product-supplier.entity.ts new file mode 100644 index 0000000..0cfbe24 --- /dev/null +++ b/src/modules/products/entities/product-supplier.entity.ts @@ -0,0 +1,51 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Product } from './product.entity'; + +@Entity({ name: 'product_suppliers', schema: 'products' }) +export class ProductSupplier { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'product_id', type: 'uuid' }) + productId: string; + + @ManyToOne(() => Product, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'product_id' }) + product: Product; + + @Index() + @Column({ name: 'supplier_id', type: 'uuid' }) + supplierId: string; + + @Column({ name: 'supplier_sku', type: 'varchar', length: 50, nullable: true }) + supplierSku?: string; + + @Column({ name: 'supplier_name', type: 'varchar', length: 200, nullable: true }) + supplierName?: string; + + @Column({ name: 'purchase_price', type: 'decimal', precision: 15, scale: 4, nullable: true }) + purchasePrice?: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ name: 'min_order_qty', type: 'decimal', precision: 15, scale: 4, default: 1 }) + minOrderQty: number; + + @Column({ name: 'lead_time_days', type: 'int', default: 0 }) + leadTimeDays: number; + + @Index() + @Column({ name: 'is_preferred', type: 'boolean', default: false }) + isPreferred: boolean; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/products/entities/product.entity.ts b/src/modules/products/entities/product.entity.ts new file mode 100644 index 0000000..51d08e0 --- /dev/null +++ b/src/modules/products/entities/product.entity.ts @@ -0,0 +1,176 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { ProductCategory } from './product-category.entity'; + +@Entity({ name: 'products', schema: 'products' }) +export class Product { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'category_id', type: 'uuid', nullable: true }) + categoryId: string; + + @ManyToOne(() => ProductCategory, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'category_id' }) + category: ProductCategory; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 50 }) + sku: string; + + @Index() + @Column({ type: 'varchar', length: 50, nullable: true }) + barcode: string; + + @Column({ type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'short_name', type: 'varchar', length: 50, nullable: true }) + shortName: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Tipo + @Index() + @Column({ name: 'product_type', type: 'varchar', length: 20, default: 'product' }) + productType: 'product' | 'service' | 'consumable' | 'kit'; + + // Precios + @Column({ name: 'sale_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + salePrice: number; + + @Column({ name: 'cost_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + costPrice: number; + + @Column({ name: 'min_sale_price', type: 'decimal', precision: 15, scale: 4, nullable: true }) + minSalePrice: number; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + // Impuestos + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16 }) + taxRate: number; + + @Column({ name: 'tax_included', type: 'boolean', default: false }) + taxIncluded: boolean; + + // SAT (Mexico) + @Column({ name: 'sat_product_code', type: 'varchar', length: 20, nullable: true }) + satProductCode: string; + + @Column({ name: 'sat_unit_code', type: 'varchar', length: 10, nullable: true }) + satUnitCode: string; + + // Unidad de medida + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + @Column({ name: 'uom_purchase', type: 'varchar', length: 20, nullable: true }) + uomPurchase: string; + + @Column({ name: 'conversion_factor', type: 'decimal', precision: 10, scale: 4, default: 1 }) + conversionFactor: number; + + // Inventario + @Column({ name: 'track_inventory', type: 'boolean', default: true }) + trackInventory: boolean; + + @Column({ name: 'min_stock', type: 'decimal', precision: 15, scale: 4, default: 0 }) + minStock: number; + + @Column({ name: 'max_stock', type: 'decimal', precision: 15, scale: 4, nullable: true }) + maxStock: number; + + @Column({ name: 'reorder_point', type: 'decimal', precision: 15, scale: 4, nullable: true }) + reorderPoint: number; + + @Column({ name: 'reorder_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true }) + reorderQuantity: number; + + // Lotes y series + @Column({ name: 'track_lots', type: 'boolean', default: false }) + trackLots: boolean; + + @Column({ name: 'track_serials', type: 'boolean', default: false }) + trackSerials: boolean; + + @Column({ name: 'track_expiry', type: 'boolean', default: false }) + trackExpiry: boolean; + + // Dimensiones + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + weight: number; + + @Column({ name: 'weight_unit', type: 'varchar', length: 10, default: 'kg' }) + weightUnit: string; + + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + length: number; + + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + width: number; + + @Column({ type: 'decimal', precision: 10, scale: 4, nullable: true }) + height: number; + + @Column({ name: 'dimension_unit', type: 'varchar', length: 10, default: 'cm' }) + dimensionUnit: string; + + // Imagenes + @Column({ name: 'image_url', type: 'varchar', length: 500, nullable: true }) + imageUrl: string; + + @Column({ type: 'text', array: true, default: '{}' }) + images: string[]; + + // Tags + @Column({ type: 'text', array: true, default: '{}' }) + tags: string[]; + + // Notas + @Column({ type: 'text', nullable: true }) + notes: string; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_sellable', type: 'boolean', default: true }) + isSellable: boolean; + + @Column({ name: 'is_purchasable', type: 'boolean', default: true }) + isPurchasable: boolean; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/products/index.ts b/src/modules/products/index.ts new file mode 100644 index 0000000..0b7ab91 --- /dev/null +++ b/src/modules/products/index.ts @@ -0,0 +1,5 @@ +export { ProductsModule, ProductsModuleOptions } from './products.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/products/products.module.ts b/src/modules/products/products.module.ts new file mode 100644 index 0000000..a1d90d4 --- /dev/null +++ b/src/modules/products/products.module.ts @@ -0,0 +1,44 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { ProductsService } from './services'; +import { ProductsController, CategoriesController } from './controllers'; +import { Product, ProductCategory } from './entities'; + +export interface ProductsModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class ProductsModule { + public router: Router; + public productsService: ProductsService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: ProductsModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const productRepository = this.dataSource.getRepository(Product); + const categoryRepository = this.dataSource.getRepository(ProductCategory); + + this.productsService = new ProductsService(productRepository, categoryRepository); + } + + private initializeRoutes(): void { + const productsController = new ProductsController(this.productsService); + const categoriesController = new CategoriesController(this.productsService); + + this.router.use(`${this.basePath}/products`, productsController.router); + this.router.use(`${this.basePath}/categories`, categoriesController.router); + } + + static getEntities(): Function[] { + return [Product, ProductCategory]; + } +} diff --git a/src/modules/products/services/index.ts b/src/modules/products/services/index.ts new file mode 100644 index 0000000..33a92cf --- /dev/null +++ b/src/modules/products/services/index.ts @@ -0,0 +1 @@ +export { ProductsService, ProductSearchParams, CategorySearchParams } from './products.service'; diff --git a/src/modules/products/services/products.service.ts b/src/modules/products/services/products.service.ts new file mode 100644 index 0000000..ee32e64 --- /dev/null +++ b/src/modules/products/services/products.service.ts @@ -0,0 +1,328 @@ +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { Product, ProductCategory } from '../entities'; +import { + CreateProductDto, + UpdateProductDto, + CreateProductCategoryDto, + UpdateProductCategoryDto, +} from '../dto'; + +export interface ProductSearchParams { + tenantId: string; + search?: string; + categoryId?: string; + productType?: 'product' | 'service' | 'consumable' | 'kit'; + isActive?: boolean; + isSellable?: boolean; + isPurchasable?: boolean; + limit?: number; + offset?: number; +} + +export interface CategorySearchParams { + tenantId: string; + search?: string; + parentId?: string; + isActive?: boolean; + limit?: number; + offset?: number; +} + +export class ProductsService { + constructor( + private readonly productRepository: Repository, + private readonly categoryRepository: Repository + ) {} + + // ==================== Products ==================== + + async findAll(params: ProductSearchParams): Promise<{ data: Product[]; total: number }> { + const { + tenantId, + search, + categoryId, + productType, + isActive, + isSellable, + isPurchasable, + limit = 50, + offset = 0, + } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (categoryId) { + baseWhere.categoryId = categoryId; + } + + if (productType) { + baseWhere.productType = productType; + } + + if (isActive !== undefined) { + baseWhere.isActive = isActive; + } + + if (isSellable !== undefined) { + baseWhere.isSellable = isSellable; + } + + if (isPurchasable !== undefined) { + baseWhere.isPurchasable = isPurchasable; + } + + if (search) { + where.push( + { ...baseWhere, name: ILike(`%${search}%`) }, + { ...baseWhere, sku: ILike(`%${search}%`) }, + { ...baseWhere, barcode: ILike(`%${search}%`) }, + { ...baseWhere, description: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.productRepository.findAndCount({ + where, + relations: ['category'], + take: limit, + skip: offset, + order: { name: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.productRepository.findOne({ + where: { id, tenantId }, + relations: ['category'], + }); + } + + async findBySku(sku: string, tenantId: string): Promise { + return this.productRepository.findOne({ + where: { sku, tenantId }, + relations: ['category'], + }); + } + + async findByBarcode(barcode: string, tenantId: string): Promise { + return this.productRepository.findOne({ + where: { barcode, tenantId }, + relations: ['category'], + }); + } + + async create(tenantId: string, dto: CreateProductDto, createdBy?: string): Promise { + // Check for existing SKU + const existingSku = await this.findBySku(dto.sku, tenantId); + if (existingSku) { + throw new Error('A product with this SKU already exists'); + } + + // Check for existing barcode + if (dto.barcode) { + const existingBarcode = await this.findByBarcode(dto.barcode, tenantId); + if (existingBarcode) { + throw new Error('A product with this barcode already exists'); + } + } + + const product = this.productRepository.create({ + ...dto, + tenantId, + createdBy, + }); + + return this.productRepository.save(product); + } + + async update( + id: string, + tenantId: string, + dto: UpdateProductDto, + updatedBy?: string + ): Promise { + const product = await this.findOne(id, tenantId); + if (!product) return null; + + // If changing SKU, check for duplicates + if (dto.sku && dto.sku !== product.sku) { + const existing = await this.findBySku(dto.sku, tenantId); + if (existing) { + throw new Error('A product with this SKU already exists'); + } + } + + // If changing barcode, check for duplicates + if (dto.barcode && dto.barcode !== product.barcode) { + const existing = await this.findByBarcode(dto.barcode, tenantId); + if (existing && existing.id !== id) { + throw new Error('A product with this barcode already exists'); + } + } + + Object.assign(product, { + ...dto, + updatedBy, + }); + + return this.productRepository.save(product); + } + + async delete(id: string, tenantId: string): Promise { + const product = await this.findOne(id, tenantId); + if (!product) return false; + + const result = await this.productRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async getSellableProducts(tenantId: string): Promise { + return this.productRepository.find({ + where: { tenantId, isActive: true, isSellable: true }, + relations: ['category'], + order: { name: 'ASC' }, + }); + } + + async getPurchasableProducts(tenantId: string): Promise { + return this.productRepository.find({ + where: { tenantId, isActive: true, isPurchasable: true }, + relations: ['category'], + order: { name: 'ASC' }, + }); + } + + // ==================== Categories ==================== + + async findAllCategories( + params: CategorySearchParams + ): Promise<{ data: ProductCategory[]; total: number }> { + const { tenantId, search, parentId, isActive, limit = 100, offset = 0 } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (parentId !== undefined) { + baseWhere.parentId = parentId || undefined; + } + + if (isActive !== undefined) { + baseWhere.isActive = isActive; + } + + if (search) { + where.push( + { ...baseWhere, name: ILike(`%${search}%`) }, + { ...baseWhere, code: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.categoryRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { sortOrder: 'ASC', name: 'ASC' }, + }); + + return { data, total }; + } + + async findCategory(id: string, tenantId: string): Promise { + return this.categoryRepository.findOne({ where: { id, tenantId } }); + } + + async findCategoryByCode(code: string, tenantId: string): Promise { + return this.categoryRepository.findOne({ where: { code, tenantId } }); + } + + async createCategory( + tenantId: string, + dto: CreateProductCategoryDto + ): Promise { + // Check for existing code + const existingCode = await this.findCategoryByCode(dto.code, tenantId); + if (existingCode) { + throw new Error('A category with this code already exists'); + } + + // Calculate hierarchy if parent exists + let hierarchyPath = `/${dto.code}`; + let hierarchyLevel = 0; + + if (dto.parentId) { + const parent = await this.findCategory(dto.parentId, tenantId); + if (parent) { + hierarchyPath = `${parent.hierarchyPath}/${dto.code}`; + hierarchyLevel = parent.hierarchyLevel + 1; + } + } + + const category = this.categoryRepository.create({ + ...dto, + tenantId, + hierarchyPath, + hierarchyLevel, + }); + + return this.categoryRepository.save(category); + } + + async updateCategory( + id: string, + tenantId: string, + dto: UpdateProductCategoryDto + ): Promise { + const category = await this.findCategory(id, tenantId); + if (!category) return null; + + // If changing code, check for duplicates + if (dto.code && dto.code !== category.code) { + const existing = await this.findCategoryByCode(dto.code, tenantId); + if (existing) { + throw new Error('A category with this code already exists'); + } + } + + Object.assign(category, dto); + return this.categoryRepository.save(category); + } + + async deleteCategory(id: string, tenantId: string): Promise { + const category = await this.findCategory(id, tenantId); + if (!category) return false; + + // Check if category has children + const children = await this.categoryRepository.findOne({ + where: { parentId: id, tenantId }, + }); + if (children) { + throw new Error('Cannot delete category with children'); + } + + // Check if category has products + const products = await this.productRepository.findOne({ + where: { categoryId: id, tenantId }, + }); + if (products) { + throw new Error('Cannot delete category with products'); + } + + const result = await this.categoryRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async getCategoryTree(tenantId: string): Promise { + const categories = await this.categoryRepository.find({ + where: { tenantId, isActive: true }, + order: { hierarchyPath: 'ASC', sortOrder: 'ASC' }, + }); + + return categories; + } +} diff --git a/src/modules/profiles/controllers/index.ts b/src/modules/profiles/controllers/index.ts new file mode 100644 index 0000000..e292287 --- /dev/null +++ b/src/modules/profiles/controllers/index.ts @@ -0,0 +1,2 @@ +export { ProfilesController } from './profiles.controller'; +export { PersonsController } from './persons.controller'; diff --git a/src/modules/profiles/controllers/persons.controller.ts b/src/modules/profiles/controllers/persons.controller.ts new file mode 100644 index 0000000..3c2e91a --- /dev/null +++ b/src/modules/profiles/controllers/persons.controller.ts @@ -0,0 +1,180 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { PersonsService } from '../services/persons.service'; +import { CreatePersonDto, UpdatePersonDto } from '../dto'; + +export class PersonsController { + public router: Router; + + constructor(private readonly personsService: PersonsService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + this.router.get('/', this.findAll.bind(this)); + this.router.get('/responsible', this.getResponsiblePersons.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.get('/email/:email', this.findByEmail.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + this.router.post('/:id/verify', this.verify.bind(this)); + this.router.post('/:id/unverify', this.unverify.bind(this)); + this.router.post('/:id/set-responsible', this.setAsResponsible.bind(this)); + } + + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const { search, email, isVerified, isResponsibleForTenant, limit, offset } = req.query; + + const result = await this.personsService.findAll({ + search: search as string, + email: email as string, + isVerified: isVerified ? isVerified === 'true' : undefined, + isResponsibleForTenant: isResponsibleForTenant ? isResponsibleForTenant === 'true' : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const person = await this.personsService.findOne(id); + + if (!person) { + res.status(404).json({ error: 'Person not found' }); + return; + } + + res.json({ data: person }); + } catch (error) { + next(error); + } + } + + private async findByEmail(req: Request, res: Response, next: NextFunction): Promise { + try { + const { email } = req.params; + const person = await this.personsService.findByEmail(email); + + if (!person) { + res.status(404).json({ error: 'Person not found' }); + return; + } + + res.json({ data: person }); + } catch (error) { + next(error); + } + } + + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const dto: CreatePersonDto = req.body; + const person = await this.personsService.create(dto); + res.status(201).json({ data: person }); + } catch (error) { + next(error); + } + } + + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const dto: UpdatePersonDto = req.body; + const person = await this.personsService.update(id, dto); + + if (!person) { + res.status(404).json({ error: 'Person not found' }); + return; + } + + res.json({ data: person }); + } catch (error) { + next(error); + } + } + + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deleted = await this.personsService.delete(id); + + if (!deleted) { + res.status(404).json({ error: 'Person not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async verify(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const verifiedBy = req.headers['x-user-id'] as string; + + const person = await this.personsService.verify(id, verifiedBy); + + if (!person) { + res.status(404).json({ error: 'Person not found' }); + return; + } + + res.json({ data: person }); + } catch (error) { + next(error); + } + } + + private async unverify(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const person = await this.personsService.unverify(id); + + if (!person) { + res.status(404).json({ error: 'Person not found' }); + return; + } + + res.json({ data: person }); + } catch (error) { + next(error); + } + } + + private async setAsResponsible(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { isResponsible } = req.body; + + const person = await this.personsService.setAsResponsible(id, isResponsible ?? true); + + if (!person) { + res.status(404).json({ error: 'Person not found' }); + return; + } + + res.json({ data: person }); + } catch (error) { + next(error); + } + } + + private async getResponsiblePersons(req: Request, res: Response, next: NextFunction): Promise { + try { + const persons = await this.personsService.getResponsiblePersons(); + res.json({ data: persons }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/profiles/controllers/profiles.controller.ts b/src/modules/profiles/controllers/profiles.controller.ts new file mode 100644 index 0000000..afa2fd9 --- /dev/null +++ b/src/modules/profiles/controllers/profiles.controller.ts @@ -0,0 +1,281 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { ProfilesService } from '../services/profiles.service'; +import { CreateProfileDto, UpdateProfileDto, AssignProfileDto, CreateProfileToolDto } from '../dto'; + +export class ProfilesController { + public router: Router; + + constructor(private readonly profilesService: ProfilesService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Profiles CRUD + this.router.get('/', this.findAll.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.get('/code/:code', this.findByCode.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + + // Profile Tools + this.router.get('/:id/tools', this.getTools.bind(this)); + this.router.post('/:id/tools', this.addTool.bind(this)); + this.router.delete('/:id/tools/:toolCode', this.removeTool.bind(this)); + + // User Profile Assignments + this.router.post('/assign', this.assignProfile.bind(this)); + this.router.delete('/assign/:userId/:profileId', this.unassignProfile.bind(this)); + this.router.get('/user/:userId', this.getUserProfiles.bind(this)); + this.router.get('/user/:userId/primary', this.getPrimaryProfile.bind(this)); + this.router.get('/user/:userId/tools', this.getUserTools.bind(this)); + + // Permissions Check + this.router.get('/user/:userId/module/:moduleCode/access', this.checkModuleAccess.bind(this)); + this.router.get('/user/:userId/platform/:platform/access', this.checkPlatformAccess.bind(this)); + } + + // ============================================ + // PROFILES CRUD + // ============================================ + + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const profiles = await this.profilesService.findAll(tenantId); + res.json({ data: profiles, total: profiles.length }); + } catch (error) { + next(error); + } + } + + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const profile = await this.profilesService.findOne(id); + + if (!profile) { + res.status(404).json({ error: 'Profile not found' }); + return; + } + + res.json({ data: profile }); + } catch (error) { + next(error); + } + } + + private async findByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const tenantId = req.headers['x-tenant-id'] as string; + const profile = await this.profilesService.findByCode(code, tenantId); + + if (!profile) { + res.status(404).json({ error: 'Profile not found' }); + return; + } + + res.json({ data: profile }); + } catch (error) { + next(error); + } + } + + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const dto: CreateProfileDto = req.body; + + const profile = await this.profilesService.create(tenantId, dto, userId); + res.status(201).json({ data: profile }); + } catch (error) { + next(error); + } + } + + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const userId = req.headers['x-user-id'] as string; + const dto: UpdateProfileDto = req.body; + + const profile = await this.profilesService.update(id, dto, userId); + + if (!profile) { + res.status(404).json({ error: 'Profile not found' }); + return; + } + + res.json({ data: profile }); + } catch (error) { + next(error); + } + } + + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deleted = await this.profilesService.delete(id); + + if (!deleted) { + res.status(404).json({ error: 'Profile not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // PROFILE TOOLS + // ============================================ + + private async getTools(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const tools = await this.profilesService.getToolsForProfile(id); + res.json({ data: tools }); + } catch (error) { + next(error); + } + } + + private async addTool(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const dto: CreateProfileToolDto = req.body; + + const tool = await this.profilesService.addTool(id, dto); + res.status(201).json({ data: tool }); + } catch (error) { + next(error); + } + } + + private async removeTool(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id, toolCode } = req.params; + const removed = await this.profilesService.removeTool(id, toolCode); + + if (!removed) { + res.status(404).json({ error: 'Tool not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // USER PROFILE ASSIGNMENTS + // ============================================ + + private async assignProfile(req: Request, res: Response, next: NextFunction): Promise { + try { + const userId = req.headers['x-user-id'] as string; + const dto: AssignProfileDto = req.body; + + const assignment = await this.profilesService.assignProfile(dto, userId); + res.status(201).json({ data: assignment }); + } catch (error) { + next(error); + } + } + + private async unassignProfile(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId, profileId } = req.params; + const unassigned = await this.profilesService.unassignProfile(userId, profileId); + + if (!unassigned) { + res.status(404).json({ error: 'Assignment not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async getUserProfiles(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId } = req.params; + const profiles = await this.profilesService.getUserProfiles(userId); + res.json({ data: profiles }); + } catch (error) { + next(error); + } + } + + private async getPrimaryProfile(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId } = req.params; + const profile = await this.profilesService.getPrimaryProfile(userId); + + if (!profile) { + res.status(404).json({ error: 'No primary profile found' }); + return; + } + + res.json({ data: profile }); + } catch (error) { + next(error); + } + } + + private async getUserTools(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId } = req.params; + const platform = req.query.platform as string | undefined; + const tools = await this.profilesService.getToolsForUser(userId, platform); + res.json({ data: tools }); + } catch (error) { + next(error); + } + } + + // ============================================ + // PERMISSIONS CHECK + // ============================================ + + private async checkModuleAccess(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId, moduleCode } = req.params; + const hasAccess = await this.profilesService.hasModuleAccess(userId, moduleCode); + const accessLevel = await this.profilesService.getModuleAccessLevel(userId, moduleCode); + + res.json({ + data: { + hasAccess, + accessLevel, + }, + }); + } catch (error) { + next(error); + } + } + + private async checkPlatformAccess(req: Request, res: Response, next: NextFunction): Promise { + try { + const { userId, platform } = req.params; + const hasAccess = await this.profilesService.hasPlatformAccess(userId, platform); + + res.json({ + data: { + hasAccess, + platform, + }, + }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/profiles/dto/create-person.dto.ts b/src/modules/profiles/dto/create-person.dto.ts new file mode 100644 index 0000000..65086df --- /dev/null +++ b/src/modules/profiles/dto/create-person.dto.ts @@ -0,0 +1,135 @@ +import { IsString, IsOptional, IsBoolean, IsEmail, IsObject, MaxLength, IsDateString } from 'class-validator'; + +export class CreatePersonDto { + @IsString() + @MaxLength(200) + fullName: string; + + @IsOptional() + @IsString() + @MaxLength(100) + firstName?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + lastName?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + maternalName?: string; + + @IsEmail() + email: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + mobilePhone?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + identificationType?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + identificationNumber?: string; + + @IsOptional() + @IsDateString() + identificationExpiry?: string; + + @IsOptional() + @IsObject() + address?: { + street?: string; + number?: string; + interior?: string; + neighborhood?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + }; + + @IsOptional() + @IsBoolean() + isResponsibleForTenant?: boolean; +} + +export class UpdatePersonDto { + @IsOptional() + @IsString() + @MaxLength(200) + fullName?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + firstName?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + lastName?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + maternalName?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + mobilePhone?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + identificationType?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + identificationNumber?: string; + + @IsOptional() + @IsDateString() + identificationExpiry?: string; + + @IsOptional() + @IsObject() + address?: { + street?: string; + number?: string; + interior?: string; + neighborhood?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + }; +} + +export class VerifyPersonDto { + @IsOptional() + @IsString() + verifiedBy?: string; +} diff --git a/src/modules/profiles/dto/create-profile.dto.ts b/src/modules/profiles/dto/create-profile.dto.ts new file mode 100644 index 0000000..24fc991 --- /dev/null +++ b/src/modules/profiles/dto/create-profile.dto.ts @@ -0,0 +1,165 @@ +import { IsString, IsOptional, IsBoolean, IsNumber, IsArray, IsObject, MaxLength, MinLength, IsUUID } from 'class-validator'; + +export class CreateProfileDto { + @IsString() + @MinLength(2) + @MaxLength(10) + code: string; + + @IsString() + @MinLength(2) + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + isSystem?: boolean; + + @IsOptional() + @IsString() + @MaxLength(20) + color?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + basePermissions?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + availableModules?: string[]; + + @IsOptional() + @IsNumber() + monthlyPrice?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + includedPlatforms?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + defaultTools?: string[]; + + @IsOptional() + @IsObject() + featureFlags?: Record; +} + +export class UpdateProfileDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + color?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + basePermissions?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + availableModules?: string[]; + + @IsOptional() + @IsNumber() + monthlyPrice?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + includedPlatforms?: string[]; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + defaultTools?: string[]; + + @IsOptional() + @IsObject() + featureFlags?: Record; +} + +export class AssignProfileDto { + @IsUUID() + userId: string; + + @IsUUID() + profileId: string; + + @IsOptional() + @IsBoolean() + isPrimary?: boolean; + + @IsOptional() + @IsString() + expiresAt?: string; +} + +export class CreateProfileToolDto { + @IsString() + @MaxLength(50) + toolCode: string; + + @IsString() + @MaxLength(100) + toolName: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + category?: string; + + @IsOptional() + @IsBoolean() + isMobileOnly?: boolean; + + @IsOptional() + @IsBoolean() + isWebOnly?: boolean; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; + + @IsOptional() + @IsObject() + configuration?: Record; + + @IsOptional() + @IsNumber() + sortOrder?: number; +} diff --git a/src/modules/profiles/dto/index.ts b/src/modules/profiles/dto/index.ts new file mode 100644 index 0000000..0070356 --- /dev/null +++ b/src/modules/profiles/dto/index.ts @@ -0,0 +1,12 @@ +export { + CreateProfileDto, + UpdateProfileDto, + AssignProfileDto, + CreateProfileToolDto, +} from './create-profile.dto'; + +export { + CreatePersonDto, + UpdatePersonDto, + VerifyPersonDto, +} from './create-person.dto'; diff --git a/src/modules/profiles/entities/index.ts b/src/modules/profiles/entities/index.ts new file mode 100644 index 0000000..780b1e7 --- /dev/null +++ b/src/modules/profiles/entities/index.ts @@ -0,0 +1,5 @@ +export { Person } from './person.entity'; +export { UserProfile } from './user-profile.entity'; +export { ProfileTool } from './profile-tool.entity'; +export { ProfileModule } from './profile-module.entity'; +export { UserProfileAssignment } from './user-profile-assignment.entity'; diff --git a/src/modules/profiles/entities/person.entity.ts b/src/modules/profiles/entities/person.entity.ts new file mode 100644 index 0000000..69ee3b5 --- /dev/null +++ b/src/modules/profiles/entities/person.entity.ts @@ -0,0 +1,85 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + OneToMany, +} from 'typeorm'; + +@Entity({ name: 'persons', schema: 'auth' }) +export class Person { + @PrimaryGeneratedColumn('uuid') + id: string; + + // Datos personales + @Column({ name: 'full_name', type: 'varchar', length: 200 }) + fullName: string; + + @Column({ name: 'first_name', type: 'varchar', length: 100, nullable: true }) + firstName: string; + + @Column({ name: 'last_name', type: 'varchar', length: 100, nullable: true }) + lastName: string; + + @Column({ name: 'maternal_name', type: 'varchar', length: 100, nullable: true }) + maternalName: string; + + // Contacto + @Index() + @Column({ type: 'varchar', length: 255 }) + email: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + phone: string; + + @Column({ name: 'mobile_phone', type: 'varchar', length: 20, nullable: true }) + mobilePhone: string; + + // Identificacion oficial + @Column({ name: 'identification_type', type: 'varchar', length: 50, nullable: true }) + identificationType: string; // INE, pasaporte, cedula_profesional + + @Column({ name: 'identification_number', type: 'varchar', length: 50, nullable: true }) + identificationNumber: string; + + @Column({ name: 'identification_expiry', type: 'date', nullable: true }) + identificationExpiry: Date; + + // Direccion + @Column({ type: 'jsonb', default: {} }) + address: { + street?: string; + number?: string; + interior?: string; + neighborhood?: string; + city?: string; + state?: string; + postalCode?: string; + country?: string; + }; + + // Metadata + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'verified_by', type: 'uuid', nullable: true }) + verifiedBy: string; + + @Column({ name: 'is_responsible_for_tenant', type: 'boolean', default: false }) + isResponsibleForTenant: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/profiles/entities/profile-module.entity.ts b/src/modules/profiles/entities/profile-module.entity.ts new file mode 100644 index 0000000..0895dfb --- /dev/null +++ b/src/modules/profiles/entities/profile-module.entity.ts @@ -0,0 +1,38 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + Unique, + Index, +} from 'typeorm'; +import { UserProfile } from './user-profile.entity'; + +@Entity({ name: 'profile_modules', schema: 'auth' }) +@Unique(['profileId', 'moduleCode']) +export class ProfileModule { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'profile_id', type: 'uuid' }) + profileId: string; + + @Column({ name: 'module_code', type: 'varchar', length: 50 }) + moduleCode: string; + + @Column({ name: 'access_level', type: 'varchar', length: 20, default: 'read' }) + accessLevel: 'read' | 'write' | 'admin'; + + @Column({ name: 'can_export', type: 'boolean', default: false }) + canExport: boolean; + + @Column({ name: 'can_print', type: 'boolean', default: true }) + canPrint: boolean; + + // Relaciones + @ManyToOne(() => UserProfile, (profile) => profile.modules, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'profile_id' }) + profile: UserProfile; +} diff --git a/src/modules/profiles/entities/profile-tool.entity.ts b/src/modules/profiles/entities/profile-tool.entity.ts new file mode 100644 index 0000000..102ffd4 --- /dev/null +++ b/src/modules/profiles/entities/profile-tool.entity.ts @@ -0,0 +1,61 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { UserProfile } from './user-profile.entity'; + +@Entity({ name: 'profile_tools', schema: 'auth' }) +@Unique(['profileId', 'toolCode']) +export class ProfileTool { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'profile_id', type: 'uuid' }) + profileId: string; + + @Index() + @Column({ name: 'tool_code', type: 'varchar', length: 50 }) + toolCode: string; + + @Column({ name: 'tool_name', type: 'varchar', length: 100 }) + toolName: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + category: string; + + @Column({ name: 'is_mobile_only', type: 'boolean', default: false }) + isMobileOnly: boolean; + + @Column({ name: 'is_web_only', type: 'boolean', default: false }) + isWebOnly: boolean; + + @Column({ type: 'varchar', length: 50, nullable: true }) + icon: string; + + @Column({ type: 'jsonb', default: {} }) + configuration: Record; + + @Column({ name: 'sort_order', type: 'integer', default: 0 }) + sortOrder: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + // Relaciones + @ManyToOne(() => UserProfile, (profile) => profile.tools, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'profile_id' }) + profile: UserProfile; +} diff --git a/src/modules/profiles/entities/user-profile-assignment.entity.ts b/src/modules/profiles/entities/user-profile-assignment.entity.ts new file mode 100644 index 0000000..39c6253 --- /dev/null +++ b/src/modules/profiles/entities/user-profile-assignment.entity.ts @@ -0,0 +1,43 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { UserProfile } from './user-profile.entity'; + +@Entity({ name: 'user_profile_assignments', schema: 'auth' }) +@Unique(['userId', 'profileId']) +export class UserProfileAssignment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Index() + @Column({ name: 'profile_id', type: 'uuid' }) + profileId: string; + + @Column({ name: 'is_primary', type: 'boolean', default: false }) + isPrimary: boolean; + + @CreateDateColumn({ name: 'assigned_at', type: 'timestamptz' }) + assignedAt: Date; + + @Column({ name: 'assigned_by', type: 'uuid', nullable: true }) + assignedBy: string; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + // Relaciones + @ManyToOne(() => UserProfile, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'profile_id' }) + profile: UserProfile; +} diff --git a/src/modules/profiles/entities/user-profile.entity.ts b/src/modules/profiles/entities/user-profile.entity.ts new file mode 100644 index 0000000..0b428e1 --- /dev/null +++ b/src/modules/profiles/entities/user-profile.entity.ts @@ -0,0 +1,89 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + OneToMany, + JoinColumn, + Unique, +} from 'typeorm'; +import { ProfileTool } from './profile-tool.entity'; +import { ProfileModule } from './profile-module.entity'; + +@Entity({ name: 'user_profiles', schema: 'auth' }) +@Unique(['tenantId', 'code']) +export class UserProfile { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid', nullable: true }) + tenantId: string; + + @Index() + @Column({ type: 'varchar', length: 10 }) + code: string; // ADM, CNT, VNT, CMP, ALM, HRH, PRD, EMP, GER, AUD + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem: boolean; + + @Column({ type: 'varchar', length: 20, nullable: true }) + color: string; + + @Column({ type: 'varchar', length: 50, nullable: true }) + icon: string; + + // Permisos base + @Column({ name: 'base_permissions', type: 'jsonb', default: [] }) + basePermissions: string[]; + + @Column({ name: 'available_modules', type: 'text', array: true, default: [] }) + availableModules: string[]; + + // Precios y plataformas + @Column({ name: 'monthly_price', type: 'decimal', precision: 10, scale: 2, default: 0 }) + monthlyPrice: number; + + @Column({ name: 'included_platforms', type: 'text', array: true, default: ['web'] }) + includedPlatforms: string[]; // web, mobile, desktop + + // Configuracion de herramientas + @Column({ name: 'default_tools', type: 'text', array: true, default: [] }) + defaultTools: string[]; + + // Feature flags especificos del perfil + @Column({ name: 'feature_flags', type: 'jsonb', default: {} }) + featureFlags: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + // Relaciones + @OneToMany(() => ProfileTool, (tool) => tool.profile, { cascade: true }) + tools: ProfileTool[]; + + @OneToMany(() => ProfileModule, (module) => module.profile, { cascade: true }) + modules: ProfileModule[]; +} diff --git a/src/modules/profiles/index.ts b/src/modules/profiles/index.ts new file mode 100644 index 0000000..c1438d2 --- /dev/null +++ b/src/modules/profiles/index.ts @@ -0,0 +1,5 @@ +export { ProfilesModule, ProfilesModuleOptions } from './profiles.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/profiles/profiles.module.ts b/src/modules/profiles/profiles.module.ts new file mode 100644 index 0000000..a25c2ec --- /dev/null +++ b/src/modules/profiles/profiles.module.ts @@ -0,0 +1,55 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { ProfilesService, PersonsService } from './services'; +import { ProfilesController, PersonsController } from './controllers'; +import { Person, UserProfile, ProfileTool, ProfileModule, UserProfileAssignment } from './entities'; + +export interface ProfilesModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class ProfilesModule { + public router: Router; + public profilesService: ProfilesService; + public personsService: PersonsService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: ProfilesModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const profileRepository = this.dataSource.getRepository(UserProfile); + const toolRepository = this.dataSource.getRepository(ProfileTool); + const moduleRepository = this.dataSource.getRepository(ProfileModule); + const assignmentRepository = this.dataSource.getRepository(UserProfileAssignment); + const personRepository = this.dataSource.getRepository(Person); + + this.profilesService = new ProfilesService( + profileRepository, + toolRepository, + moduleRepository, + assignmentRepository + ); + + this.personsService = new PersonsService(personRepository); + } + + private initializeRoutes(): void { + const profilesController = new ProfilesController(this.profilesService); + const personsController = new PersonsController(this.personsService); + + this.router.use(`${this.basePath}/profiles`, profilesController.router); + this.router.use(`${this.basePath}/persons`, personsController.router); + } + + static getEntities(): Function[] { + return [Person, UserProfile, ProfileTool, ProfileModule, UserProfileAssignment]; + } +} diff --git a/src/modules/profiles/services/index.ts b/src/modules/profiles/services/index.ts new file mode 100644 index 0000000..50793ae --- /dev/null +++ b/src/modules/profiles/services/index.ts @@ -0,0 +1,2 @@ +export { ProfilesService } from './profiles.service'; +export { PersonsService, PersonSearchParams } from './persons.service'; diff --git a/src/modules/profiles/services/persons.service.ts b/src/modules/profiles/services/persons.service.ts new file mode 100644 index 0000000..142df28 --- /dev/null +++ b/src/modules/profiles/services/persons.service.ts @@ -0,0 +1,162 @@ +import { Repository, FindOptionsWhere, Like, ILike } from 'typeorm'; +import { Person } from '../entities'; +import { CreatePersonDto, UpdatePersonDto } from '../dto'; + +export interface PersonSearchParams { + search?: string; + email?: string; + isVerified?: boolean; + isResponsibleForTenant?: boolean; + limit?: number; + offset?: number; +} + +export class PersonsService { + constructor(private readonly personRepository: Repository) {} + + async findAll(params: PersonSearchParams = {}): Promise<{ data: Person[]; total: number }> { + const { search, email, isVerified, isResponsibleForTenant, limit = 50, offset = 0 } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = {}; + + if (email) { + baseWhere.email = email; + } + + if (isVerified !== undefined) { + baseWhere.isVerified = isVerified; + } + + if (isResponsibleForTenant !== undefined) { + baseWhere.isResponsibleForTenant = isResponsibleForTenant; + } + + if (search) { + where.push( + { ...baseWhere, fullName: ILike(`%${search}%`) }, + { ...baseWhere, email: ILike(`%${search}%`) }, + { ...baseWhere, identificationNumber: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.personRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { fullName: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string): Promise { + return this.personRepository.findOne({ where: { id } }); + } + + async findByEmail(email: string): Promise { + return this.personRepository.findOne({ where: { email } }); + } + + async findByIdentification(type: string, number: string): Promise { + return this.personRepository.findOne({ + where: { identificationType: type, identificationNumber: number }, + }); + } + + async create(dto: CreatePersonDto): Promise { + // Check for existing email + const existing = await this.findByEmail(dto.email); + if (existing) { + throw new Error('A person with this email already exists'); + } + + // Check for existing identification + if (dto.identificationType && dto.identificationNumber) { + const existingId = await this.findByIdentification(dto.identificationType, dto.identificationNumber); + if (existingId) { + throw new Error('A person with this identification already exists'); + } + } + + const person = this.personRepository.create({ + ...dto, + identificationExpiry: dto.identificationExpiry ? new Date(dto.identificationExpiry) : undefined, + }); + + return this.personRepository.save(person); + } + + async update(id: string, dto: UpdatePersonDto): Promise { + const person = await this.findOne(id); + if (!person) return null; + + // If changing email, check for duplicates + if (dto.email && dto.email !== person.email) { + const existing = await this.findByEmail(dto.email); + if (existing) { + throw new Error('A person with this email already exists'); + } + } + + // If changing identification, check for duplicates + if (dto.identificationType && dto.identificationNumber) { + const existingId = await this.findByIdentification(dto.identificationType, dto.identificationNumber); + if (existingId && existingId.id !== id) { + throw new Error('A person with this identification already exists'); + } + } + + Object.assign(person, { + ...dto, + identificationExpiry: dto.identificationExpiry ? new Date(dto.identificationExpiry) : person.identificationExpiry, + }); + + return this.personRepository.save(person); + } + + async delete(id: string): Promise { + const result = await this.personRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async verify(id: string, verifiedBy: string): Promise { + const person = await this.findOne(id); + if (!person) return null; + + person.isVerified = true; + person.verifiedAt = new Date(); + person.verifiedBy = verifiedBy; + + return this.personRepository.save(person); + } + + async unverify(id: string): Promise { + const person = await this.findOne(id); + if (!person) return null; + + person.isVerified = false; + person.verifiedAt = null as any; + person.verifiedBy = null as any; + + return this.personRepository.save(person); + } + + async setAsResponsible(id: string, isResponsible: boolean = true): Promise { + const person = await this.findOne(id); + if (!person) return null; + + person.isResponsibleForTenant = isResponsible; + + return this.personRepository.save(person); + } + + async getResponsiblePersons(): Promise { + return this.personRepository.find({ + where: { isResponsibleForTenant: true }, + order: { fullName: 'ASC' }, + }); + } +} diff --git a/src/modules/profiles/services/profiles.service.ts b/src/modules/profiles/services/profiles.service.ts new file mode 100644 index 0000000..cef72e5 --- /dev/null +++ b/src/modules/profiles/services/profiles.service.ts @@ -0,0 +1,272 @@ +import { Repository, FindOptionsWhere, In } from 'typeorm'; +import { UserProfile, ProfileTool, ProfileModule, UserProfileAssignment } from '../entities'; +import { CreateProfileDto, UpdateProfileDto, AssignProfileDto, CreateProfileToolDto } from '../dto'; + +export class ProfilesService { + constructor( + private readonly profileRepository: Repository, + private readonly toolRepository: Repository, + private readonly moduleRepository: Repository, + private readonly assignmentRepository: Repository + ) {} + + // ============================================ + // PROFILE CRUD + // ============================================ + + async findAll(tenantId?: string): Promise { + const where: FindOptionsWhere = {}; + + if (tenantId) { + // Include system profiles (tenantId = null) and tenant-specific profiles + return this.profileRepository.find({ + where: [{ tenantId }, { tenantId: undefined, isSystem: true }], + relations: ['tools', 'modules'], + order: { code: 'ASC' }, + }); + } + + return this.profileRepository.find({ + relations: ['tools', 'modules'], + order: { code: 'ASC' }, + }); + } + + async findOne(id: string): Promise { + return this.profileRepository.findOne({ + where: { id }, + relations: ['tools', 'modules'], + }); + } + + async findByCode(code: string, tenantId?: string): Promise { + if (tenantId) { + // First try tenant-specific, then system profile + const tenantProfile = await this.profileRepository.findOne({ + where: { code, tenantId }, + relations: ['tools', 'modules'], + }); + + if (tenantProfile) return tenantProfile; + + return this.profileRepository.findOne({ + where: { code, isSystem: true }, + relations: ['tools', 'modules'], + }); + } + + return this.profileRepository.findOne({ + where: { code }, + relations: ['tools', 'modules'], + }); + } + + async create(tenantId: string, dto: CreateProfileDto, createdBy?: string): Promise { + const profile = this.profileRepository.create({ + ...dto, + tenantId, + createdBy, + }); + + return this.profileRepository.save(profile); + } + + async update(id: string, dto: UpdateProfileDto, updatedBy?: string): Promise { + const profile = await this.findOne(id); + if (!profile) return null; + + // Don't allow updating system profiles + if (profile.isSystem) { + throw new Error('Cannot update system profiles'); + } + + Object.assign(profile, dto, { updatedBy }); + return this.profileRepository.save(profile); + } + + async delete(id: string): Promise { + const profile = await this.findOne(id); + if (!profile) return false; + + // Don't allow deleting system profiles + if (profile.isSystem) { + throw new Error('Cannot delete system profiles'); + } + + await this.profileRepository.softDelete(id); + return true; + } + + // ============================================ + // PROFILE TOOLS + // ============================================ + + async addTool(profileId: string, dto: CreateProfileToolDto): Promise { + const tool = this.toolRepository.create({ + ...dto, + profileId, + }); + + return this.toolRepository.save(tool); + } + + async removeTool(profileId: string, toolCode: string): Promise { + const result = await this.toolRepository.delete({ profileId, toolCode }); + return (result.affected ?? 0) > 0; + } + + async getToolsForProfile(profileId: string): Promise { + return this.toolRepository.find({ + where: { profileId, isActive: true }, + order: { sortOrder: 'ASC' }, + }); + } + + async getToolsForUser(userId: string, platform?: string): Promise { + const assignments = await this.assignmentRepository.find({ + where: { userId }, + relations: ['profile', 'profile.tools'], + }); + + const allTools: ProfileTool[] = []; + + for (const assignment of assignments) { + if (assignment.profile?.tools) { + for (const tool of assignment.profile.tools) { + if (!tool.isActive) continue; + + // Filter by platform + if (platform === 'mobile' && tool.isWebOnly) continue; + if (platform === 'web' && tool.isMobileOnly) continue; + + allTools.push(tool); + } + } + } + + // Remove duplicates and sort + const uniqueTools = allTools.filter( + (tool, index, self) => index === self.findIndex((t) => t.toolCode === tool.toolCode) + ); + + return uniqueTools.sort((a, b) => a.sortOrder - b.sortOrder); + } + + // ============================================ + // USER PROFILE ASSIGNMENTS + // ============================================ + + async assignProfile(dto: AssignProfileDto, assignedBy?: string): Promise { + // If setting as primary, unset other primary assignments for this user + if (dto.isPrimary) { + await this.assignmentRepository.update({ userId: dto.userId, isPrimary: true }, { isPrimary: false }); + } + + const existing = await this.assignmentRepository.findOne({ + where: { userId: dto.userId, profileId: dto.profileId }, + }); + + if (existing) { + Object.assign(existing, { + isPrimary: dto.isPrimary ?? existing.isPrimary, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : existing.expiresAt, + }); + return this.assignmentRepository.save(existing); + } + + const assignment = this.assignmentRepository.create({ + userId: dto.userId, + profileId: dto.profileId, + isPrimary: dto.isPrimary ?? false, + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : undefined, + assignedBy, + }); + + return this.assignmentRepository.save(assignment); + } + + async unassignProfile(userId: string, profileId: string): Promise { + const result = await this.assignmentRepository.delete({ userId, profileId }); + return (result.affected ?? 0) > 0; + } + + async getUserProfiles(userId: string): Promise { + const assignments = await this.assignmentRepository.find({ + where: { userId }, + relations: ['profile', 'profile.tools', 'profile.modules'], + }); + + return assignments.map((a) => a.profile).filter((p) => p != null); + } + + async getPrimaryProfile(userId: string): Promise { + const assignment = await this.assignmentRepository.findOne({ + where: { userId, isPrimary: true }, + relations: ['profile', 'profile.tools', 'profile.modules'], + }); + + return assignment?.profile ?? null; + } + + async getUsersWithProfile(profileId: string): Promise { + const assignments = await this.assignmentRepository.find({ + where: { profileId }, + select: ['userId'], + }); + + return assignments.map((a) => a.userId); + } + + // ============================================ + // PERMISSIONS CHECK + // ============================================ + + async hasModuleAccess(userId: string, moduleCode: string): Promise { + const profiles = await this.getUserProfiles(userId); + + for (const profile of profiles) { + // Admin has access to all + if (profile.availableModules.includes('all')) return true; + + // Check specific module + if (profile.availableModules.includes(moduleCode)) return true; + + // Check profile modules with access level + const moduleAccess = profile.modules?.find((m) => m.moduleCode === moduleCode); + if (moduleAccess) return true; + } + + return false; + } + + async getModuleAccessLevel(userId: string, moduleCode: string): Promise { + const profiles = await this.getUserProfiles(userId); + + let highestAccess: string | null = null; + const accessLevels = { read: 1, write: 2, admin: 3 }; + + for (const profile of profiles) { + const moduleAccess = profile.modules?.find((m) => m.moduleCode === moduleCode); + if (moduleAccess) { + const currentLevel = accessLevels[moduleAccess.accessLevel as keyof typeof accessLevels] ?? 0; + const highestLevel = highestAccess ? accessLevels[highestAccess as keyof typeof accessLevels] ?? 0 : 0; + + if (currentLevel > highestLevel) { + highestAccess = moduleAccess.accessLevel; + } + } + } + + return highestAccess; + } + + async hasPlatformAccess(userId: string, platform: string): Promise { + const profiles = await this.getUserProfiles(userId); + + for (const profile of profiles) { + if (profile.includedPlatforms.includes(platform)) return true; + } + + return false; + } +} 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/controllers/index.ts b/src/modules/purchases/controllers/index.ts new file mode 100644 index 0000000..36e07df --- /dev/null +++ b/src/modules/purchases/controllers/index.ts @@ -0,0 +1,89 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { PurchasesService } from '../services'; + +export class PurchasesController { + public router: Router; + constructor(private readonly purchasesService: PurchasesService) { + this.router = Router(); + this.router.get('/', this.findAll.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + this.router.post('/:id/confirm', this.confirm.bind(this)); + this.router.post('/:id/receive', this.receive.bind(this)); + } + + private async findAll(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const { supplierId, status, buyerId, limit, offset } = req.query; + const result = await this.purchasesService.findAll({ tenantId, supplierId: supplierId as string, status: status as string, buyerId: buyerId as string, limit: limit ? parseInt(limit as string) : undefined, offset: offset ? parseInt(offset as string) : undefined }); + res.json(result); + } catch (e) { next(e); } + } + + private async findOne(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.purchasesService.findOne(req.params.id, tenantId); + if (!order) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } + + private async create(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.purchasesService.create(tenantId, req.body, userId); + res.status(201).json({ data: order }); + } catch (e) { next(e); } + } + + private async update(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.purchasesService.update(req.params.id, tenantId, req.body, userId); + if (!order) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } + + private async delete(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const deleted = await this.purchasesService.delete(req.params.id, tenantId); + if (!deleted) { res.status(404).json({ error: 'Not found' }); return; } + res.status(204).send(); + } catch (e) { next(e); } + } + + private async confirm(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.purchasesService.confirmOrder(req.params.id, tenantId, userId); + if (!order) { res.status(400).json({ error: 'Cannot confirm' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } + + private async receive(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.purchasesService.receiveOrder(req.params.id, tenantId, userId); + if (!order) { res.status(400).json({ error: 'Cannot receive' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } +} diff --git a/src/modules/purchases/dto/index.ts b/src/modules/purchases/dto/index.ts new file mode 100644 index 0000000..1e79596 --- /dev/null +++ b/src/modules/purchases/dto/index.ts @@ -0,0 +1,39 @@ +import { IsString, IsOptional, IsNumber, IsUUID, IsDateString, IsArray, IsObject, MaxLength, IsEnum, Min } from 'class-validator'; + +export class CreatePurchaseOrderDto { + @IsUUID() supplierId: string; + @IsOptional() @IsString() @MaxLength(200) supplierName?: string; + @IsOptional() @IsString() supplierEmail?: string; + @IsOptional() @IsObject() shippingAddress?: object; + @IsOptional() @IsDateString() expectedDate?: string; + @IsOptional() @IsUUID() buyerId?: string; + @IsOptional() @IsUUID() warehouseId?: string; + @IsOptional() @IsString() @MaxLength(3) currency?: string; + @IsOptional() @IsNumber() paymentTermDays?: number; + @IsOptional() @IsString() paymentMethod?: string; + @IsOptional() @IsString() @MaxLength(10) incoterm?: string; + @IsOptional() @IsString() supplierReference?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsArray() items?: CreatePurchaseItemDto[]; +} + +export class CreatePurchaseItemDto { + @IsOptional() @IsUUID() productId?: string; + @IsString() @MaxLength(200) productName: string; + @IsOptional() @IsString() @MaxLength(50) productSku?: string; + @IsOptional() @IsString() @MaxLength(50) supplierSku?: string; + @IsNumber() @Min(0) quantity: number; + @IsOptional() @IsString() @MaxLength(20) uom?: string; + @IsNumber() @Min(0) unitPrice: number; + @IsOptional() @IsNumber() discountPercent?: number; + @IsOptional() @IsNumber() taxRate?: number; +} + +export class UpdatePurchaseOrderDto { + @IsOptional() @IsObject() shippingAddress?: object; + @IsOptional() @IsDateString() expectedDate?: string; + @IsOptional() @IsUUID() warehouseId?: string; + @IsOptional() @IsString() supplierReference?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsEnum(['draft', 'sent', 'confirmed', 'partial', 'received', 'cancelled']) status?: string; +} diff --git a/src/modules/purchases/entities/index.ts b/src/modules/purchases/entities/index.ts new file mode 100644 index 0000000..d4c36a1 --- /dev/null +++ b/src/modules/purchases/entities/index.ts @@ -0,0 +1,4 @@ +export { PurchaseOrder } from './purchase-order.entity'; +export { PurchaseOrderItem } from './purchase-order-item.entity'; +export { PurchaseReceipt } from './purchase-receipt.entity'; +export { PurchaseReceiptItem } from './purchase-receipt-item.entity'; diff --git a/src/modules/purchases/entities/purchase-order-item.entity.ts b/src/modules/purchases/entities/purchase-order-item.entity.ts new file mode 100644 index 0000000..107491c --- /dev/null +++ b/src/modules/purchases/entities/purchase-order-item.entity.ts @@ -0,0 +1,84 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { PurchaseOrder } from './purchase-order.entity'; + +@Entity({ name: 'purchase_order_items', schema: 'purchases' }) +export class PurchaseOrderItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'order_id', type: 'uuid' }) + orderId: string; + + @ManyToOne(() => PurchaseOrder, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'order_id' }) + order: PurchaseOrder; + + @Index() + @Column({ name: 'product_id', type: 'uuid', nullable: true }) + productId?: string; + + @Column({ name: 'line_number', type: 'int', default: 1 }) + lineNumber: number; + + @Column({ name: 'product_sku', type: 'varchar', length: 50, nullable: true }) + productSku?: string; + + @Column({ name: 'product_name', type: 'varchar', length: 200 }) + productName: string; + + @Column({ name: 'supplier_sku', type: 'varchar', length: 50, nullable: true }) + supplierSku?: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) + quantity: number; + + @Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityReceived: number; + + @Column({ name: 'quantity_returned', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityReturned: number; + + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + unitPrice: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16.00 }) + taxRate: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true }) + lotNumber?: string; + + @Column({ name: 'expiry_date', type: 'date', nullable: true }) + expiryDate?: Date; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: 'pending' | 'partial' | 'received' | 'cancelled'; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/purchases/entities/purchase-order.entity.ts b/src/modules/purchases/entities/purchase-order.entity.ts new file mode 100644 index 0000000..2c87c74 --- /dev/null +++ b/src/modules/purchases/entities/purchase-order.entity.ts @@ -0,0 +1,98 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'purchase_orders', schema: 'purchases' }) +export class PurchaseOrder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'order_number', type: 'varchar', length: 30 }) + orderNumber: string; + + @Index() + @Column({ name: 'supplier_id', type: 'uuid' }) + supplierId: string; + + @Column({ name: 'supplier_name', type: 'varchar', length: 200, nullable: true }) + supplierName: string; + + @Column({ name: 'supplier_email', type: 'varchar', length: 255, nullable: true }) + supplierEmail: string; + + @Column({ name: 'shipping_address', type: 'jsonb', nullable: true }) + shippingAddress: object; + + @Column({ name: 'order_date', type: 'date', default: () => 'CURRENT_DATE' }) + orderDate: Date; + + @Column({ name: 'expected_date', type: 'date', nullable: true }) + expectedDate: Date; + + @Column({ name: 'received_date', type: 'date', nullable: true }) + receivedDate: Date; + + @Column({ name: 'buyer_id', type: 'uuid', nullable: true }) + buyerId: string; + + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId: string; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'shipping_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + shippingAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'payment_term_days', type: 'int', default: 0 }) + paymentTermDays: number; + + @Column({ name: 'payment_method', type: 'varchar', length: 50, nullable: true }) + paymentMethod: string; + + @Column({ type: 'varchar', length: 10, nullable: true }) + incoterm: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'sent' | 'confirmed' | 'partial' | 'received' | 'cancelled'; + + @Column({ name: 'supplier_reference', type: 'varchar', length: 100, nullable: true }) + supplierReference: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'internal_notes', type: 'text', nullable: true }) + internalNotes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/purchases/entities/purchase-receipt-item.entity.ts b/src/modules/purchases/entities/purchase-receipt-item.entity.ts new file mode 100644 index 0000000..8cd3eeb --- /dev/null +++ b/src/modules/purchases/entities/purchase-receipt-item.entity.ts @@ -0,0 +1,57 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { PurchaseReceipt } from './purchase-receipt.entity'; + +@Entity({ name: 'purchase_receipt_items', schema: 'purchases' }) +export class PurchaseReceiptItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'receipt_id', type: 'uuid' }) + receiptId: string; + + @ManyToOne(() => PurchaseReceipt, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'receipt_id' }) + receipt: PurchaseReceipt; + + @Column({ name: 'order_item_id', type: 'uuid', nullable: true }) + orderItemId?: string; + + @Index() + @Column({ name: 'product_id', type: 'uuid', nullable: true }) + productId?: string; + + @Column({ name: 'quantity_expected', type: 'decimal', precision: 15, scale: 4, nullable: true }) + quantityExpected?: number; + + @Column({ name: 'quantity_received', type: 'decimal', precision: 15, scale: 4 }) + quantityReceived: number; + + @Column({ name: 'quantity_rejected', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityRejected: number; + + @Index() + @Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true }) + lotNumber?: string; + + @Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true }) + serialNumber?: string; + + @Column({ name: 'expiry_date', type: 'date', nullable: true }) + expiryDate?: Date; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; + + @Column({ name: 'quality_status', type: 'varchar', length: 20, default: 'pending' }) + qualityStatus: 'pending' | 'approved' | 'rejected' | 'quarantine'; + + @Column({ name: 'quality_notes', type: 'text', nullable: true }) + qualityNotes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/purchases/entities/purchase-receipt.entity.ts b/src/modules/purchases/entities/purchase-receipt.entity.ts new file mode 100644 index 0000000..03da5cc --- /dev/null +++ b/src/modules/purchases/entities/purchase-receipt.entity.ts @@ -0,0 +1,52 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'purchase_receipts', schema: 'purchases' }) +export class PurchaseReceipt { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'order_id', type: 'uuid' }) + orderId: string; + + @Column({ name: 'receipt_number', type: 'varchar', length: 30 }) + receiptNumber: string; + + @Column({ name: 'receipt_date', type: 'date', default: () => 'CURRENT_DATE' }) + receiptDate: Date; + + @Column({ name: 'received_by', type: 'uuid', nullable: true }) + receivedBy?: string; + + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId?: string; + + @Column({ name: 'location_id', type: 'uuid', nullable: true }) + locationId?: string; + + @Column({ name: 'supplier_delivery_note', type: 'varchar', length: 100, nullable: true }) + supplierDeliveryNote?: string; + + @Column({ name: 'supplier_invoice_number', type: 'varchar', length: 100, nullable: true }) + supplierInvoiceNumber?: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'confirmed' | 'cancelled'; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/purchases/index.ts b/src/modules/purchases/index.ts new file mode 100644 index 0000000..465e763 --- /dev/null +++ b/src/modules/purchases/index.ts @@ -0,0 +1,5 @@ +export { PurchasesModule, PurchasesModuleOptions } from './purchases.module'; +export * from './entities'; +export { PurchasesService } from './services'; +export { PurchasesController } from './controllers'; +export * from './dto'; 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.module.ts b/src/modules/purchases/purchases.module.ts new file mode 100644 index 0000000..f684d9b --- /dev/null +++ b/src/modules/purchases/purchases.module.ts @@ -0,0 +1,39 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { PurchasesService } from './services'; +import { PurchasesController } from './controllers'; +import { PurchaseOrder } from './entities'; + +export interface PurchasesModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class PurchasesModule { + public router: Router; + public purchasesService: PurchasesService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: PurchasesModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const orderRepository = this.dataSource.getRepository(PurchaseOrder); + this.purchasesService = new PurchasesService(orderRepository); + } + + private initializeRoutes(): void { + const purchasesController = new PurchasesController(this.purchasesService); + this.router.use(`${this.basePath}/purchase-orders`, purchasesController.router); + } + + static getEntities(): Function[] { + return [PurchaseOrder]; + } +} 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/purchases/services/index.ts b/src/modules/purchases/services/index.ts new file mode 100644 index 0000000..72daa36 --- /dev/null +++ b/src/modules/purchases/services/index.ts @@ -0,0 +1,66 @@ +import { Repository, FindOptionsWhere } from 'typeorm'; +import { PurchaseOrder } from '../entities'; +import { CreatePurchaseOrderDto, UpdatePurchaseOrderDto } from '../dto'; + +export interface PurchaseSearchParams { + tenantId: string; + supplierId?: string; + status?: string; + buyerId?: string; + limit?: number; + offset?: number; +} + +export class PurchasesService { + constructor(private readonly orderRepository: Repository) {} + + async findAll(params: PurchaseSearchParams): Promise<{ data: PurchaseOrder[]; total: number }> { + const { tenantId, supplierId, status, buyerId, limit = 50, offset = 0 } = params; + const where: FindOptionsWhere = { tenantId }; + if (supplierId) where.supplierId = supplierId; + if (status) where.status = status as any; + if (buyerId) where.buyerId = buyerId; + const [data, total] = await this.orderRepository.findAndCount({ where, take: limit, skip: offset, order: { createdAt: 'DESC' } }); + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.orderRepository.findOne({ where: { id, tenantId } }); + } + + async create(tenantId: string, dto: CreatePurchaseOrderDto, createdBy?: string): Promise { + const count = await this.orderRepository.count({ where: { tenantId } }); + const orderNumber = `OC-${String(count + 1).padStart(6, '0')}`; + const order = this.orderRepository.create({ ...dto, tenantId, orderNumber, createdBy, orderDate: new Date(), expectedDate: dto.expectedDate ? new Date(dto.expectedDate) : undefined }); + return this.orderRepository.save(order); + } + + async update(id: string, tenantId: string, dto: UpdatePurchaseOrderDto, updatedBy?: string): Promise { + const order = await this.findOne(id, tenantId); + if (!order) return null; + Object.assign(order, { ...dto, updatedBy }); + return this.orderRepository.save(order); + } + + async delete(id: string, tenantId: string): Promise { + const result = await this.orderRepository.softDelete({ id, tenantId }); + return (result.affected ?? 0) > 0; + } + + async confirmOrder(id: string, tenantId: string, userId?: string): Promise { + const order = await this.findOne(id, tenantId); + if (!order || order.status !== 'draft') return null; + order.status = 'confirmed'; + order.updatedBy = userId; + return this.orderRepository.save(order); + } + + async receiveOrder(id: string, tenantId: string, userId?: string): Promise { + const order = await this.findOne(id, tenantId); + if (!order || !['sent', 'confirmed', 'partial'].includes(order.status)) return null; + order.status = 'received'; + order.receivedDate = new Date(); + order.updatedBy = userId; + return this.orderRepository.save(order); + } +} diff --git a/src/modules/reports/controllers/index.ts b/src/modules/reports/controllers/index.ts new file mode 100644 index 0000000..9624d2d --- /dev/null +++ b/src/modules/reports/controllers/index.ts @@ -0,0 +1,230 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { ReportsService } from '../services'; + +export class ReportsController { + public router: Router; + + constructor(private readonly reportsService: ReportsService) { + this.router = Router(); + + // Sales Reports + this.router.get('/sales', this.getSalesReport.bind(this)); + this.router.get('/sales/top-products', this.getTopSellingProducts.bind(this)); + this.router.get('/sales/top-customers', this.getTopCustomers.bind(this)); + + // Inventory Reports + this.router.get('/inventory', this.getInventoryReport.bind(this)); + this.router.get('/inventory/movements', this.getStockMovementReport.bind(this)); + + // Financial Reports + this.router.get('/financial', this.getFinancialReport.bind(this)); + this.router.get('/financial/receivables', this.getAccountsReceivable.bind(this)); + this.router.get('/financial/payables', this.getAccountsPayable.bind(this)); + } + + // ==================== Sales Reports ==================== + + private async getSalesReport(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const { startDate, endDate, partnerId, productId, groupBy } = req.query; + + const start = startDate ? new Date(startDate as string) : new Date(new Date().setDate(new Date().getDate() - 30)); + const end = endDate ? new Date(endDate as string) : new Date(); + + const result = await this.reportsService.getSalesReport({ + tenantId, + startDate: start, + endDate: end, + partnerId: partnerId as string, + productId: productId as string, + groupBy: groupBy as 'day' | 'week' | 'month' | 'partner' | 'product', + }); + + res.json(result); + } catch (e) { + next(e); + } + } + + private async getTopSellingProducts(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const { startDate, endDate, limit } = req.query; + + const start = startDate ? new Date(startDate as string) : new Date(new Date().setDate(new Date().getDate() - 30)); + const end = endDate ? new Date(endDate as string) : new Date(); + + const data = await this.reportsService.getTopSellingProducts( + tenantId, + start, + end, + limit ? parseInt(limit as string) : 10 + ); + + res.json({ data }); + } catch (e) { + next(e); + } + } + + private async getTopCustomers(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const { startDate, endDate, limit } = req.query; + + const start = startDate ? new Date(startDate as string) : new Date(new Date().setDate(new Date().getDate() - 30)); + const end = endDate ? new Date(endDate as string) : new Date(); + + const data = await this.reportsService.getTopCustomers( + tenantId, + start, + end, + limit ? parseInt(limit as string) : 10 + ); + + res.json({ data }); + } catch (e) { + next(e); + } + } + + // ==================== Inventory Reports ==================== + + private async getInventoryReport(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const { warehouseId, productId, categoryId, lowStockOnly } = req.query; + + const result = await this.reportsService.getInventoryReport({ + tenantId, + warehouseId: warehouseId as string, + productId: productId as string, + categoryId: categoryId as string, + lowStockOnly: lowStockOnly === 'true', + }); + + res.json(result); + } catch (e) { + next(e); + } + } + + private async getStockMovementReport(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const { startDate, endDate, warehouseId } = req.query; + + const start = startDate ? new Date(startDate as string) : new Date(new Date().setDate(new Date().getDate() - 30)); + const end = endDate ? new Date(endDate as string) : new Date(); + + const data = await this.reportsService.getStockMovementReport( + tenantId, + start, + end, + warehouseId as string + ); + + res.json({ data }); + } catch (e) { + next(e); + } + } + + // ==================== Financial Reports ==================== + + private async getFinancialReport(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const { startDate, endDate, reportType } = req.query; + + const start = startDate ? new Date(startDate as string) : new Date(new Date().setMonth(new Date().getMonth() - 12)); + const end = endDate ? new Date(endDate as string) : new Date(); + + const result = await this.reportsService.getFinancialReport({ + tenantId, + startDate: start, + endDate: end, + reportType: (reportType as any) || 'profit_loss', + }); + + res.json(result); + } catch (e) { + next(e); + } + } + + private async getAccountsReceivable(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const now = new Date(); + const result = await this.reportsService.getFinancialReport({ + tenantId, + startDate: now, + endDate: now, + reportType: 'accounts_receivable', + }); + + res.json(result); + } catch (e) { + next(e); + } + } + + private async getAccountsPayable(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID required' }); + return; + } + + const now = new Date(); + const result = await this.reportsService.getFinancialReport({ + tenantId, + startDate: now, + endDate: now, + reportType: 'accounts_payable', + }); + + res.json(result); + } catch (e) { + next(e); + } + } +} diff --git a/src/modules/reports/index.ts b/src/modules/reports/index.ts new file mode 100644 index 0000000..f6b9bfb --- /dev/null +++ b/src/modules/reports/index.ts @@ -0,0 +1,3 @@ +export { ReportsModule, ReportsModuleOptions } from './reports.module'; +export { ReportsService } from './services'; +export { ReportsController } from './controllers'; 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.module.ts b/src/modules/reports/reports.module.ts new file mode 100644 index 0000000..8d55c3a --- /dev/null +++ b/src/modules/reports/reports.module.ts @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { ReportsService } from './services'; +import { ReportsController } from './controllers'; + +export interface ReportsModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class ReportsModule { + public router: Router; + public reportsService: ReportsService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: ReportsModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + this.reportsService = new ReportsService(this.dataSource); + } + + private initializeRoutes(): void { + const reportsController = new ReportsController(this.reportsService); + this.router.use(`${this.basePath}/reports`, reportsController.router); + } + + // Reports module doesn't have its own entities - it uses data from other modules + static getEntities(): Function[] { + return []; + } +} 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/reports/services/index.ts b/src/modules/reports/services/index.ts new file mode 100644 index 0000000..6de33ff --- /dev/null +++ b/src/modules/reports/services/index.ts @@ -0,0 +1,526 @@ +import { DataSource } from 'typeorm'; + +export interface ReportDateRange { + startDate: Date; + endDate: Date; +} + +export interface SalesReportParams { + tenantId: string; + startDate: Date; + endDate: Date; + partnerId?: string; + productId?: string; + groupBy?: 'day' | 'week' | 'month' | 'partner' | 'product'; +} + +export interface InventoryReportParams { + tenantId: string; + warehouseId?: string; + productId?: string; + categoryId?: string; + lowStockOnly?: boolean; +} + +export interface FinancialReportParams { + tenantId: string; + startDate: Date; + endDate: Date; + reportType: 'income' | 'expenses' | 'profit_loss' | 'cash_flow' | 'accounts_receivable' | 'accounts_payable'; +} + +export interface SalesSummary { + totalOrders: number; + totalRevenue: number; + averageOrderValue: number; + totalItems: number; +} + +export interface InventorySummary { + totalProducts: number; + totalValue: number; + lowStockItems: number; + outOfStockItems: number; +} + +export interface FinancialSummary { + totalIncome: number; + totalExpenses: number; + netProfit: number; + margin: number; +} + +export class ReportsService { + constructor(private readonly dataSource: DataSource) {} + + // ==================== Sales Reports ==================== + + async getSalesReport(params: SalesReportParams): Promise<{ + summary: SalesSummary; + data: any[]; + period: ReportDateRange; + }> { + const { tenantId, startDate, endDate, partnerId, productId, groupBy = 'day' } = params; + + // Build query based on groupBy + let query = ` + SELECT + COUNT(DISTINCT o.id) as order_count, + SUM(o.total) as total_revenue, + SUM(oi.quantity) as total_items + FROM sales.sales_orders o + LEFT JOIN sales.sales_order_items oi ON o.id = oi.order_id + WHERE o.tenant_id = $1 + AND o.status NOT IN ('cancelled', 'draft') + AND o.order_date BETWEEN $2 AND $3 + `; + + const queryParams: any[] = [tenantId, startDate, endDate]; + let paramIndex = 4; + + if (partnerId) { + query += ` AND o.partner_id = $${paramIndex}`; + queryParams.push(partnerId); + paramIndex++; + } + + if (productId) { + query += ` AND oi.product_id = $${paramIndex}`; + queryParams.push(productId); + } + + try { + const result = await this.dataSource.query(query, queryParams); + const row = result[0] || {}; + + const summary: SalesSummary = { + totalOrders: parseInt(row.order_count) || 0, + totalRevenue: parseFloat(row.total_revenue) || 0, + averageOrderValue: row.order_count > 0 ? parseFloat(row.total_revenue) / parseInt(row.order_count) : 0, + totalItems: parseInt(row.total_items) || 0, + }; + + // Get detailed data grouped by period + const detailQuery = this.buildSalesDetailQuery(groupBy); + const detailParams = [tenantId, startDate, endDate]; + const detailData = await this.dataSource.query(detailQuery, detailParams); + + return { + summary, + data: detailData, + period: { startDate, endDate }, + }; + } catch (error) { + // Return empty data if tables don't exist yet + return { + summary: { totalOrders: 0, totalRevenue: 0, averageOrderValue: 0, totalItems: 0 }, + data: [], + period: { startDate, endDate }, + }; + } + } + + private buildSalesDetailQuery(groupBy: string): string { + const groupByClause = { + day: "DATE_TRUNC('day', o.order_date)", + week: "DATE_TRUNC('week', o.order_date)", + month: "DATE_TRUNC('month', o.order_date)", + partner: 'o.partner_id', + product: 'oi.product_id', + }[groupBy] || "DATE_TRUNC('day', o.order_date)"; + + return ` + SELECT + ${groupByClause} as period, + COUNT(DISTINCT o.id) as order_count, + SUM(o.total) as total_revenue + FROM sales.sales_orders o + LEFT JOIN sales.sales_order_items oi ON o.id = oi.order_id + WHERE o.tenant_id = $1 + AND o.status NOT IN ('cancelled', 'draft') + AND o.order_date BETWEEN $2 AND $3 + GROUP BY ${groupByClause} + ORDER BY period DESC + `; + } + + async getTopSellingProducts( + tenantId: string, + startDate: Date, + endDate: Date, + limit: number = 10 + ): Promise { + try { + const query = ` + SELECT + oi.product_id, + oi.product_name, + SUM(oi.quantity) as total_quantity, + SUM(oi.line_total) as total_revenue + FROM sales.sales_order_items oi + JOIN sales.sales_orders o ON oi.order_id = o.id + WHERE o.tenant_id = $1 + AND o.status NOT IN ('cancelled', 'draft') + AND o.order_date BETWEEN $2 AND $3 + GROUP BY oi.product_id, oi.product_name + ORDER BY total_revenue DESC + LIMIT $4 + `; + + return await this.dataSource.query(query, [tenantId, startDate, endDate, limit]); + } catch { + return []; + } + } + + async getTopCustomers( + tenantId: string, + startDate: Date, + endDate: Date, + limit: number = 10 + ): Promise { + try { + const query = ` + SELECT + o.partner_id, + o.partner_name, + COUNT(o.id) as order_count, + SUM(o.total) as total_revenue + FROM sales.sales_orders o + WHERE o.tenant_id = $1 + AND o.status NOT IN ('cancelled', 'draft') + AND o.order_date BETWEEN $2 AND $3 + GROUP BY o.partner_id, o.partner_name + ORDER BY total_revenue DESC + LIMIT $4 + `; + + return await this.dataSource.query(query, [tenantId, startDate, endDate, limit]); + } catch { + return []; + } + } + + // ==================== Inventory Reports ==================== + + async getInventoryReport(params: InventoryReportParams): Promise<{ + summary: InventorySummary; + data: any[]; + }> { + const { tenantId, warehouseId, productId, categoryId, lowStockOnly } = params; + + try { + let query = ` + SELECT + COUNT(DISTINCT sl.product_id) as total_products, + SUM(sl.quantity_on_hand * COALESCE(sl.unit_cost, 0)) as total_value, + COUNT(CASE WHEN sl.quantity_on_hand <= 0 THEN 1 END) as out_of_stock, + COUNT(CASE WHEN sl.quantity_on_hand > 0 AND sl.quantity_on_hand <= 10 THEN 1 END) as low_stock + FROM inventory.stock_levels sl + WHERE sl.tenant_id = $1 + `; + + const queryParams: any[] = [tenantId]; + let paramIndex = 2; + + if (warehouseId) { + query += ` AND sl.warehouse_id = $${paramIndex}`; + queryParams.push(warehouseId); + paramIndex++; + } + + if (productId) { + query += ` AND sl.product_id = $${paramIndex}`; + queryParams.push(productId); + } + + const result = await this.dataSource.query(query, queryParams); + const row = result[0] || {}; + + const summary: InventorySummary = { + totalProducts: parseInt(row.total_products) || 0, + totalValue: parseFloat(row.total_value) || 0, + lowStockItems: parseInt(row.low_stock) || 0, + outOfStockItems: parseInt(row.out_of_stock) || 0, + }; + + // Get detailed stock levels + let detailQuery = ` + SELECT + sl.product_id, + p.name as product_name, + p.sku, + sl.warehouse_id, + w.name as warehouse_name, + sl.quantity_on_hand, + sl.quantity_reserved, + sl.quantity_available, + sl.unit_cost, + (sl.quantity_on_hand * COALESCE(sl.unit_cost, 0)) as total_value + FROM inventory.stock_levels sl + LEFT JOIN products.products p ON sl.product_id = p.id + LEFT JOIN inventory.warehouses w ON sl.warehouse_id = w.id + WHERE sl.tenant_id = $1 + `; + + const detailParams: any[] = [tenantId]; + let detailIndex = 2; + + if (warehouseId) { + detailQuery += ` AND sl.warehouse_id = $${detailIndex}`; + detailParams.push(warehouseId); + detailIndex++; + } + + if (lowStockOnly) { + detailQuery += ` AND sl.quantity_on_hand <= 10`; + } + + detailQuery += ` ORDER BY sl.quantity_on_hand ASC LIMIT 100`; + + const detailData = await this.dataSource.query(detailQuery, detailParams); + + return { summary, data: detailData }; + } catch { + return { + summary: { totalProducts: 0, totalValue: 0, lowStockItems: 0, outOfStockItems: 0 }, + data: [], + }; + } + } + + async getStockMovementReport( + tenantId: string, + startDate: Date, + endDate: Date, + warehouseId?: string + ): Promise { + try { + let query = ` + SELECT + sm.movement_type, + COUNT(*) as movement_count, + SUM(sm.quantity) as total_quantity, + SUM(sm.total_cost) as total_value + FROM inventory.stock_movements sm + WHERE sm.tenant_id = $1 + AND sm.status = 'confirmed' + AND sm.created_at BETWEEN $2 AND $3 + `; + + const params: any[] = [tenantId, startDate, endDate]; + + if (warehouseId) { + query += ` AND (sm.source_warehouse_id = $4 OR sm.dest_warehouse_id = $4)`; + params.push(warehouseId); + } + + query += ` GROUP BY sm.movement_type ORDER BY total_quantity DESC`; + + return await this.dataSource.query(query, params); + } catch { + return []; + } + } + + // ==================== Financial Reports ==================== + + async getFinancialReport(params: FinancialReportParams): Promise<{ + summary: FinancialSummary; + data: any[]; + period: ReportDateRange; + }> { + const { tenantId, startDate, endDate, reportType } = params; + + try { + switch (reportType) { + case 'income': + return await this.getIncomeReport(tenantId, startDate, endDate); + case 'expenses': + return await this.getExpensesReport(tenantId, startDate, endDate); + case 'profit_loss': + return await this.getProfitLossReport(tenantId, startDate, endDate); + case 'accounts_receivable': + return await this.getAccountsReceivableReport(tenantId); + case 'accounts_payable': + return await this.getAccountsPayableReport(tenantId); + default: + return await this.getProfitLossReport(tenantId, startDate, endDate); + } + } catch { + return { + summary: { totalIncome: 0, totalExpenses: 0, netProfit: 0, margin: 0 }, + data: [], + period: { startDate, endDate }, + }; + } + } + + private async getIncomeReport( + tenantId: string, + startDate: Date, + endDate: Date + ): Promise<{ summary: FinancialSummary; data: any[]; period: ReportDateRange }> { + const query = ` + SELECT + DATE_TRUNC('month', i.invoice_date) as period, + SUM(i.total) as total_income + FROM billing.invoices i + WHERE i.tenant_id = $1 + AND i.invoice_type = 'sale' + AND i.status NOT IN ('cancelled', 'voided', 'draft') + AND i.invoice_date BETWEEN $2 AND $3 + GROUP BY DATE_TRUNC('month', i.invoice_date) + ORDER BY period + `; + + const data = await this.dataSource.query(query, [tenantId, startDate, endDate]); + const totalIncome = data.reduce((sum: number, row: any) => sum + parseFloat(row.total_income || 0), 0); + + return { + summary: { totalIncome, totalExpenses: 0, netProfit: totalIncome, margin: 100 }, + data, + period: { startDate, endDate }, + }; + } + + private async getExpensesReport( + tenantId: string, + startDate: Date, + endDate: Date + ): Promise<{ summary: FinancialSummary; data: any[]; period: ReportDateRange }> { + const query = ` + SELECT + DATE_TRUNC('month', i.invoice_date) as period, + SUM(i.total) as total_expenses + FROM billing.invoices i + WHERE i.tenant_id = $1 + AND i.invoice_type = 'purchase' + AND i.status NOT IN ('cancelled', 'voided', 'draft') + AND i.invoice_date BETWEEN $2 AND $3 + GROUP BY DATE_TRUNC('month', i.invoice_date) + ORDER BY period + `; + + const data = await this.dataSource.query(query, [tenantId, startDate, endDate]); + const totalExpenses = data.reduce((sum: number, row: any) => sum + parseFloat(row.total_expenses || 0), 0); + + return { + summary: { totalIncome: 0, totalExpenses, netProfit: -totalExpenses, margin: 0 }, + data, + period: { startDate, endDate }, + }; + } + + private async getProfitLossReport( + tenantId: string, + startDate: Date, + endDate: Date + ): Promise<{ summary: FinancialSummary; data: any[]; period: ReportDateRange }> { + const incomeQuery = ` + SELECT COALESCE(SUM(total), 0) as total + FROM billing.invoices + WHERE tenant_id = $1 + AND invoice_type = 'sale' + AND status NOT IN ('cancelled', 'voided', 'draft') + AND invoice_date BETWEEN $2 AND $3 + `; + + const expensesQuery = ` + SELECT COALESCE(SUM(total), 0) as total + FROM billing.invoices + WHERE tenant_id = $1 + AND invoice_type = 'purchase' + AND status NOT IN ('cancelled', 'voided', 'draft') + AND invoice_date BETWEEN $2 AND $3 + `; + + const [incomeResult, expensesResult] = await Promise.all([ + this.dataSource.query(incomeQuery, [tenantId, startDate, endDate]), + this.dataSource.query(expensesQuery, [tenantId, startDate, endDate]), + ]); + + const totalIncome = parseFloat(incomeResult[0]?.total) || 0; + const totalExpenses = parseFloat(expensesResult[0]?.total) || 0; + const netProfit = totalIncome - totalExpenses; + const margin = totalIncome > 0 ? (netProfit / totalIncome) * 100 : 0; + + return { + summary: { totalIncome, totalExpenses, netProfit, margin }, + data: [{ totalIncome, totalExpenses, netProfit, margin }], + period: { startDate, endDate }, + }; + } + + private async getAccountsReceivableReport( + tenantId: string + ): Promise<{ summary: FinancialSummary; data: any[]; period: ReportDateRange }> { + const query = ` + SELECT + i.id, + i.invoice_number, + i.partner_name, + i.total, + i.amount_paid, + (i.total - i.amount_paid) as amount_due, + i.due_date, + CASE + WHEN i.due_date < CURRENT_DATE THEN 'overdue' + WHEN i.due_date < CURRENT_DATE + INTERVAL '7 days' THEN 'due_soon' + ELSE 'current' + END as status + FROM billing.invoices i + WHERE i.tenant_id = $1 + AND i.invoice_type = 'sale' + AND i.status IN ('validated', 'sent', 'partial') + AND (i.total - i.amount_paid) > 0 + ORDER BY i.due_date ASC + `; + + const data = await this.dataSource.query(query, [tenantId]); + const totalIncome = data.reduce((sum: number, row: any) => sum + parseFloat(row.amount_due || 0), 0); + + const now = new Date(); + return { + summary: { totalIncome, totalExpenses: 0, netProfit: totalIncome, margin: 0 }, + data, + period: { startDate: now, endDate: now }, + }; + } + + private async getAccountsPayableReport( + tenantId: string + ): Promise<{ summary: FinancialSummary; data: any[]; period: ReportDateRange }> { + const query = ` + SELECT + i.id, + i.invoice_number, + i.partner_name, + i.total, + i.amount_paid, + (i.total - i.amount_paid) as amount_due, + i.due_date, + CASE + WHEN i.due_date < CURRENT_DATE THEN 'overdue' + WHEN i.due_date < CURRENT_DATE + INTERVAL '7 days' THEN 'due_soon' + ELSE 'current' + END as status + FROM billing.invoices i + WHERE i.tenant_id = $1 + AND i.invoice_type = 'purchase' + AND i.status IN ('validated', 'sent', 'partial') + AND (i.total - i.amount_paid) > 0 + ORDER BY i.due_date ASC + `; + + const data = await this.dataSource.query(query, [tenantId]); + const totalExpenses = data.reduce((sum: number, row: any) => sum + parseFloat(row.amount_due || 0), 0); + + const now = new Date(); + return { + summary: { totalIncome: 0, totalExpenses, netProfit: -totalExpenses, margin: 0 }, + data, + period: { startDate: now, endDate: now }, + }; + } +} 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/controllers/index.ts b/src/modules/sales/controllers/index.ts new file mode 100644 index 0000000..049434b --- /dev/null +++ b/src/modules/sales/controllers/index.ts @@ -0,0 +1,177 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { SalesService } from '../services'; +import { CreateQuotationDto, UpdateQuotationDto, CreateSalesOrderDto, UpdateSalesOrderDto } from '../dto'; + +export class QuotationsController { + public router: Router; + constructor(private readonly salesService: SalesService) { + this.router = Router(); + this.router.get('/', this.findAll.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + this.router.post('/:id/convert', this.convert.bind(this)); + } + + private async findAll(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const { partnerId, status, salesRepId, limit, offset } = req.query; + const result = await this.salesService.findAllQuotations({ tenantId, partnerId: partnerId as string, status: status as string, salesRepId: salesRepId as string, limit: limit ? parseInt(limit as string) : undefined, offset: offset ? parseInt(offset as string) : undefined }); + res.json(result); + } catch (e) { next(e); } + } + + private async findOne(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const quotation = await this.salesService.findQuotation(req.params.id, tenantId); + if (!quotation) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: quotation }); + } catch (e) { next(e); } + } + + private async create(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const quotation = await this.salesService.createQuotation(tenantId, req.body, userId); + res.status(201).json({ data: quotation }); + } catch (e) { next(e); } + } + + private async update(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const quotation = await this.salesService.updateQuotation(req.params.id, tenantId, req.body, userId); + if (!quotation) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: quotation }); + } catch (e) { next(e); } + } + + private async delete(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const deleted = await this.salesService.deleteQuotation(req.params.id, tenantId); + if (!deleted) { res.status(404).json({ error: 'Not found' }); return; } + res.status(204).send(); + } catch (e) { next(e); } + } + + private async convert(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.salesService.convertQuotationToOrder(req.params.id, tenantId, userId); + res.json({ data: order }); + } catch (e) { next(e); } + } +} + +export class SalesOrdersController { + public router: Router; + constructor(private readonly salesService: SalesService) { + this.router = Router(); + this.router.get('/', this.findAll.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + this.router.post('/:id/confirm', this.confirm.bind(this)); + this.router.post('/:id/ship', this.ship.bind(this)); + this.router.post('/:id/deliver', this.deliver.bind(this)); + } + + private async findAll(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const { partnerId, status, salesRepId, limit, offset } = req.query; + const result = await this.salesService.findAllOrders({ tenantId, partnerId: partnerId as string, status: status as string, salesRepId: salesRepId as string, limit: limit ? parseInt(limit as string) : undefined, offset: offset ? parseInt(offset as string) : undefined }); + res.json(result); + } catch (e) { next(e); } + } + + private async findOne(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.salesService.findOrder(req.params.id, tenantId); + if (!order) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } + + private async create(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.salesService.createSalesOrder(tenantId, req.body, userId); + res.status(201).json({ data: order }); + } catch (e) { next(e); } + } + + private async update(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.salesService.updateSalesOrder(req.params.id, tenantId, req.body, userId); + if (!order) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } + + private async delete(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const deleted = await this.salesService.deleteSalesOrder(req.params.id, tenantId); + if (!deleted) { res.status(404).json({ error: 'Not found' }); return; } + res.status(204).send(); + } catch (e) { next(e); } + } + + private async confirm(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.salesService.confirmOrder(req.params.id, tenantId, userId); + if (!order) { res.status(400).json({ error: 'Cannot confirm' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } + + private async ship(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const { trackingNumber, carrier } = req.body; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.salesService.shipOrder(req.params.id, tenantId, trackingNumber, carrier, userId); + if (!order) { res.status(400).json({ error: 'Cannot ship' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } + + private async deliver(req: Request, res: Response, next: NextFunction) { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + if (!tenantId) { res.status(400).json({ error: 'Tenant ID required' }); return; } + const order = await this.salesService.deliverOrder(req.params.id, tenantId, userId); + if (!order) { res.status(400).json({ error: 'Cannot deliver' }); return; } + res.json({ data: order }); + } catch (e) { next(e); } + } +} 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/dto/index.ts b/src/modules/sales/dto/index.ts new file mode 100644 index 0000000..6741874 --- /dev/null +++ b/src/modules/sales/dto/index.ts @@ -0,0 +1,82 @@ +import { IsString, IsOptional, IsNumber, IsUUID, IsDateString, IsArray, IsObject, MaxLength, IsEnum, Min } from 'class-validator'; + +export class CreateQuotationDto { + @IsUUID() partnerId: string; + @IsOptional() @IsString() @MaxLength(200) partnerName?: string; + @IsOptional() @IsString() partnerEmail?: string; + @IsOptional() @IsObject() billingAddress?: object; + @IsOptional() @IsObject() shippingAddress?: object; + @IsOptional() @IsDateString() quotationDate?: string; + @IsOptional() @IsDateString() validUntil?: string; + @IsOptional() @IsUUID() salesRepId?: string; + @IsOptional() @IsString() @MaxLength(3) currency?: string; + @IsOptional() @IsNumber() paymentTermDays?: number; + @IsOptional() @IsString() paymentMethod?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsString() termsAndConditions?: string; + @IsOptional() @IsArray() items?: CreateQuotationItemDto[]; +} + +export class CreateQuotationItemDto { + @IsOptional() @IsUUID() productId?: string; + @IsString() @MaxLength(200) productName: string; + @IsOptional() @IsString() @MaxLength(50) productSku?: string; + @IsOptional() @IsString() description?: string; + @IsNumber() @Min(0) quantity: number; + @IsOptional() @IsString() @MaxLength(20) uom?: string; + @IsNumber() @Min(0) unitPrice: number; + @IsOptional() @IsNumber() discountPercent?: number; + @IsOptional() @IsNumber() taxRate?: number; +} + +export class UpdateQuotationDto { + @IsOptional() @IsUUID() partnerId?: string; + @IsOptional() @IsString() @MaxLength(200) partnerName?: string; + @IsOptional() @IsObject() billingAddress?: object; + @IsOptional() @IsObject() shippingAddress?: object; + @IsOptional() @IsDateString() validUntil?: string; + @IsOptional() @IsString() @MaxLength(3) currency?: string; + @IsOptional() @IsNumber() paymentTermDays?: number; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsEnum(['draft', 'sent', 'accepted', 'rejected', 'expired']) status?: string; +} + +export class CreateSalesOrderDto { + @IsUUID() partnerId: string; + @IsOptional() @IsString() @MaxLength(200) partnerName?: string; + @IsOptional() @IsUUID() quotationId?: string; + @IsOptional() @IsObject() billingAddress?: object; + @IsOptional() @IsObject() shippingAddress?: object; + @IsOptional() @IsDateString() requestedDate?: string; + @IsOptional() @IsDateString() promisedDate?: string; + @IsOptional() @IsUUID() salesRepId?: string; + @IsOptional() @IsUUID() warehouseId?: string; + @IsOptional() @IsString() @MaxLength(3) currency?: string; + @IsOptional() @IsNumber() paymentTermDays?: number; + @IsOptional() @IsString() paymentMethod?: string; + @IsOptional() @IsString() shippingMethod?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsArray() items?: CreateOrderItemDto[]; +} + +export class CreateOrderItemDto { + @IsOptional() @IsUUID() productId?: string; + @IsString() @MaxLength(200) productName: string; + @IsOptional() @IsString() @MaxLength(50) productSku?: string; + @IsNumber() @Min(0) quantity: number; + @IsOptional() @IsString() @MaxLength(20) uom?: string; + @IsNumber() @Min(0) unitPrice: number; + @IsOptional() @IsNumber() unitCost?: number; + @IsOptional() @IsNumber() discountPercent?: number; + @IsOptional() @IsNumber() taxRate?: number; +} + +export class UpdateSalesOrderDto { + @IsOptional() @IsObject() shippingAddress?: object; + @IsOptional() @IsDateString() promisedDate?: string; + @IsOptional() @IsString() shippingMethod?: string; + @IsOptional() @IsString() trackingNumber?: string; + @IsOptional() @IsString() carrier?: string; + @IsOptional() @IsString() notes?: string; + @IsOptional() @IsEnum(['draft', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled']) status?: string; +} diff --git a/src/modules/sales/entities/index.ts b/src/modules/sales/entities/index.ts new file mode 100644 index 0000000..cca5d8f --- /dev/null +++ b/src/modules/sales/entities/index.ts @@ -0,0 +1,4 @@ +export { Quotation } from './quotation.entity'; +export { QuotationItem } from './quotation-item.entity'; +export { SalesOrder } from './sales-order.entity'; +export { SalesOrderItem } from './sales-order-item.entity'; diff --git a/src/modules/sales/entities/quotation-item.entity.ts b/src/modules/sales/entities/quotation-item.entity.ts new file mode 100644 index 0000000..95928bd --- /dev/null +++ b/src/modules/sales/entities/quotation-item.entity.ts @@ -0,0 +1,65 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Quotation } from './quotation.entity'; + +@Entity({ name: 'quotation_items', schema: 'sales' }) +export class QuotationItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'quotation_id', type: 'uuid' }) + quotationId: string; + + @ManyToOne(() => Quotation, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'quotation_id' }) + quotation: Quotation; + + @Index() + @Column({ name: 'product_id', type: 'uuid', nullable: true }) + productId?: string; + + @Column({ name: 'line_number', type: 'int', default: 1 }) + lineNumber: number; + + @Column({ name: 'product_sku', type: 'varchar', length: 50, nullable: true }) + productSku?: string; + + @Column({ name: 'product_name', type: 'varchar', length: 200 }) + productName: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) + quantity: number; + + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + unitPrice: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16.00 }) + taxRate: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/sales/entities/quotation.entity.ts b/src/modules/sales/entities/quotation.entity.ts new file mode 100644 index 0000000..88e9bdd --- /dev/null +++ b/src/modules/sales/entities/quotation.entity.ts @@ -0,0 +1,101 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index, OneToMany } from 'typeorm'; + +@Entity({ name: 'quotations', schema: 'sales' }) +export class Quotation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'quotation_number', type: 'varchar', length: 30 }) + quotationNumber: string; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @Column({ name: 'partner_name', type: 'varchar', length: 200, nullable: true }) + partnerName: string; + + @Column({ name: 'partner_email', type: 'varchar', length: 255, nullable: true }) + partnerEmail: string; + + @Column({ name: 'billing_address', type: 'jsonb', nullable: true }) + billingAddress: object; + + @Column({ name: 'shipping_address', type: 'jsonb', nullable: true }) + shippingAddress: object; + + @Column({ name: 'quotation_date', type: 'date', default: () => 'CURRENT_DATE' }) + quotationDate: Date; + + @Column({ name: 'valid_until', type: 'date', nullable: true }) + validUntil: Date; + + @Column({ name: 'expected_close_date', type: 'date', nullable: true }) + expectedCloseDate: Date; + + @Column({ name: 'sales_rep_id', type: 'uuid', nullable: true }) + salesRepId: string; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'payment_term_days', type: 'int', default: 0 }) + paymentTermDays: number; + + @Column({ name: 'payment_method', type: 'varchar', length: 50, nullable: true }) + paymentMethod: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'sent' | 'accepted' | 'rejected' | 'expired' | 'converted'; + + @Column({ name: 'converted_to_order', type: 'boolean', default: false }) + convertedToOrder: boolean; + + @Column({ name: 'order_id', type: 'uuid', nullable: true }) + orderId: string; + + @Column({ name: 'converted_at', type: 'timestamptz', nullable: true }) + convertedAt: Date; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'internal_notes', type: 'text', nullable: true }) + internalNotes: string; + + @Column({ name: 'terms_and_conditions', type: 'text', nullable: true }) + termsAndConditions: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/sales/entities/sales-order-item.entity.ts b/src/modules/sales/entities/sales-order-item.entity.ts new file mode 100644 index 0000000..3a38976 --- /dev/null +++ b/src/modules/sales/entities/sales-order-item.entity.ts @@ -0,0 +1,90 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { SalesOrder } from './sales-order.entity'; + +@Entity({ name: 'sales_order_items', schema: 'sales' }) +export class SalesOrderItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'order_id', type: 'uuid' }) + orderId: string; + + @ManyToOne(() => SalesOrder, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'order_id' }) + order: SalesOrder; + + @Index() + @Column({ name: 'product_id', type: 'uuid', nullable: true }) + productId?: string; + + @Column({ name: 'line_number', type: 'int', default: 1 }) + lineNumber: number; + + @Column({ name: 'product_sku', type: 'varchar', length: 50, nullable: true }) + productSku?: string; + + @Column({ name: 'product_name', type: 'varchar', length: 200 }) + productName: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'decimal', precision: 15, scale: 4, default: 1 }) + quantity: number; + + @Column({ name: 'quantity_reserved', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityReserved: number; + + @Column({ name: 'quantity_shipped', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityShipped: number; + + @Column({ name: 'quantity_delivered', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityDelivered: number; + + @Column({ name: 'quantity_returned', type: 'decimal', precision: 15, scale: 4, default: 0 }) + quantityReturned: number; + + @Column({ type: 'varchar', length: 20, default: 'PZA' }) + uom: string; + + @Column({ name: 'unit_price', type: 'decimal', precision: 15, scale: 4, default: 0 }) + unitPrice: number; + + @Column({ name: 'unit_cost', type: 'decimal', precision: 15, scale: 4, default: 0 }) + unitCost: number; + + @Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPercent: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'tax_rate', type: 'decimal', precision: 5, scale: 2, default: 16.00 }) + taxRate: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true }) + lotNumber?: string; + + @Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true }) + serialNumber?: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'pending' }) + status: 'pending' | 'reserved' | 'shipped' | 'delivered' | 'cancelled'; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/sales/entities/sales-order.entity.ts b/src/modules/sales/entities/sales-order.entity.ts new file mode 100644 index 0000000..6d08bb1 --- /dev/null +++ b/src/modules/sales/entities/sales-order.entity.ts @@ -0,0 +1,113 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, Index } from 'typeorm'; + +@Entity({ name: 'sales_orders', schema: 'sales' }) +export class SalesOrder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'order_number', type: 'varchar', length: 30 }) + orderNumber: string; + + @Column({ name: 'quotation_id', type: 'uuid', nullable: true }) + quotationId: string; + + @Index() + @Column({ name: 'partner_id', type: 'uuid' }) + partnerId: string; + + @Column({ name: 'partner_name', type: 'varchar', length: 200, nullable: true }) + partnerName: string; + + @Column({ name: 'partner_email', type: 'varchar', length: 255, nullable: true }) + partnerEmail: string; + + @Column({ name: 'billing_address', type: 'jsonb', nullable: true }) + billingAddress: object; + + @Column({ name: 'shipping_address', type: 'jsonb', nullable: true }) + shippingAddress: object; + + @Column({ name: 'order_date', type: 'date', default: () => 'CURRENT_DATE' }) + orderDate: Date; + + @Column({ name: 'requested_date', type: 'date', nullable: true }) + requestedDate: Date; + + @Column({ name: 'promised_date', type: 'date', nullable: true }) + promisedDate: Date; + + @Column({ name: 'shipped_date', type: 'date', nullable: true }) + shippedDate: Date; + + @Column({ name: 'delivered_date', type: 'date', nullable: true }) + deliveredDate: Date; + + @Column({ name: 'sales_rep_id', type: 'uuid', nullable: true }) + salesRepId: string; + + @Column({ name: 'warehouse_id', type: 'uuid', nullable: true }) + warehouseId: string; + + @Column({ type: 'varchar', length: 3, default: 'MXN' }) + currency: string; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + subtotal: number; + + @Column({ name: 'tax_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + taxAmount: number; + + @Column({ name: 'discount_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + discountAmount: number; + + @Column({ name: 'shipping_amount', type: 'decimal', precision: 15, scale: 2, default: 0 }) + shippingAmount: number; + + @Column({ type: 'decimal', precision: 15, scale: 2, default: 0 }) + total: number; + + @Column({ name: 'payment_term_days', type: 'int', default: 0 }) + paymentTermDays: number; + + @Column({ name: 'payment_method', type: 'varchar', length: 50, nullable: true }) + paymentMethod: string; + + @Index() + @Column({ type: 'varchar', length: 20, default: 'draft' }) + status: 'draft' | 'confirmed' | 'processing' | 'shipped' | 'delivered' | 'cancelled'; + + @Column({ name: 'shipping_method', type: 'varchar', length: 50, nullable: true }) + shippingMethod: string; + + @Column({ name: 'tracking_number', type: 'varchar', length: 100, nullable: true }) + trackingNumber: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + carrier: string; + + @Column({ type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'internal_notes', type: 'text', nullable: true }) + internalNotes: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy?: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy?: string; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/sales/index.ts b/src/modules/sales/index.ts new file mode 100644 index 0000000..705ff68 --- /dev/null +++ b/src/modules/sales/index.ts @@ -0,0 +1,5 @@ +export { SalesModule, SalesModuleOptions } from './sales.module'; +export * from './entities'; +export { SalesService } from './services'; +export { QuotationsController, SalesOrdersController } from './controllers'; +export * from './dto'; 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.module.ts b/src/modules/sales/sales.module.ts new file mode 100644 index 0000000..ae5fa33 --- /dev/null +++ b/src/modules/sales/sales.module.ts @@ -0,0 +1,42 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { SalesService } from './services'; +import { QuotationsController, SalesOrdersController } from './controllers'; +import { Quotation, SalesOrder } from './entities'; + +export interface SalesModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class SalesModule { + public router: Router; + public salesService: SalesService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: SalesModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const quotationRepository = this.dataSource.getRepository(Quotation); + const orderRepository = this.dataSource.getRepository(SalesOrder); + this.salesService = new SalesService(quotationRepository, orderRepository); + } + + private initializeRoutes(): void { + const quotationsController = new QuotationsController(this.salesService); + const ordersController = new SalesOrdersController(this.salesService); + this.router.use(`${this.basePath}/quotations`, quotationsController.router); + this.router.use(`${this.basePath}/sales-orders`, ordersController.router); + } + + static getEntities(): Function[] { + return [Quotation, SalesOrder]; + } +} 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/sales/services/index.ts b/src/modules/sales/services/index.ts new file mode 100644 index 0000000..fcde7fd --- /dev/null +++ b/src/modules/sales/services/index.ts @@ -0,0 +1,144 @@ +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { Quotation, SalesOrder } from '../entities'; +import { CreateQuotationDto, UpdateQuotationDto, CreateSalesOrderDto, UpdateSalesOrderDto } from '../dto'; + +export interface SalesSearchParams { + tenantId: string; + search?: string; + partnerId?: string; + status?: string; + salesRepId?: string; + fromDate?: Date; + toDate?: Date; + limit?: number; + offset?: number; +} + +export class SalesService { + constructor( + private readonly quotationRepository: Repository, + private readonly orderRepository: Repository + ) {} + + async findAllQuotations(params: SalesSearchParams): Promise<{ data: Quotation[]; total: number }> { + const { tenantId, search, partnerId, status, salesRepId, limit = 50, offset = 0 } = params; + const where: FindOptionsWhere = { tenantId }; + if (partnerId) where.partnerId = partnerId; + if (status) where.status = status as any; + if (salesRepId) where.salesRepId = salesRepId; + const [data, total] = await this.quotationRepository.findAndCount({ where, take: limit, skip: offset, order: { createdAt: 'DESC' } }); + return { data, total }; + } + + async findQuotation(id: string, tenantId: string): Promise { + return this.quotationRepository.findOne({ where: { id, tenantId } }); + } + + async createQuotation(tenantId: string, dto: CreateQuotationDto, createdBy?: string): Promise { + const count = await this.quotationRepository.count({ where: { tenantId } }); + const quotationNumber = `COT-${String(count + 1).padStart(6, '0')}`; + const quotation = this.quotationRepository.create({ ...dto, tenantId, quotationNumber, createdBy, quotationDate: dto.quotationDate ? new Date(dto.quotationDate) : new Date(), validUntil: dto.validUntil ? new Date(dto.validUntil) : undefined }); + return this.quotationRepository.save(quotation); + } + + async updateQuotation(id: string, tenantId: string, dto: UpdateQuotationDto, updatedBy?: string): Promise { + const quotation = await this.findQuotation(id, tenantId); + if (!quotation) return null; + Object.assign(quotation, { ...dto, updatedBy }); + return this.quotationRepository.save(quotation); + } + + async deleteQuotation(id: string, tenantId: string): Promise { + const result = await this.quotationRepository.softDelete({ id, tenantId }); + return (result.affected ?? 0) > 0; + } + + async convertQuotationToOrder(id: string, tenantId: string, userId?: string): Promise { + const quotation = await this.findQuotation(id, tenantId); + if (!quotation) throw new Error('Quotation not found'); + if (quotation.convertedToOrder) throw new Error('Quotation already converted'); + + const order = await this.createSalesOrder(tenantId, { + partnerId: quotation.partnerId, + partnerName: quotation.partnerName, + quotationId: quotation.id, + billingAddress: quotation.billingAddress, + shippingAddress: quotation.shippingAddress, + currency: quotation.currency, + paymentTermDays: quotation.paymentTermDays, + paymentMethod: quotation.paymentMethod, + notes: quotation.notes, + }, userId); + + quotation.convertedToOrder = true; + quotation.orderId = order.id; + quotation.convertedAt = new Date(); + quotation.status = 'converted'; + await this.quotationRepository.save(quotation); + + return order; + } + + async findAllOrders(params: SalesSearchParams): Promise<{ data: SalesOrder[]; total: number }> { + const { tenantId, search, partnerId, status, salesRepId, limit = 50, offset = 0 } = params; + const where: FindOptionsWhere = { tenantId }; + if (partnerId) where.partnerId = partnerId; + if (status) where.status = status as any; + if (salesRepId) where.salesRepId = salesRepId; + const [data, total] = await this.orderRepository.findAndCount({ where, take: limit, skip: offset, order: { createdAt: 'DESC' } }); + return { data, total }; + } + + async findOrder(id: string, tenantId: string): Promise { + return this.orderRepository.findOne({ where: { id, tenantId } }); + } + + async createSalesOrder(tenantId: string, dto: CreateSalesOrderDto, createdBy?: string): Promise { + const count = await this.orderRepository.count({ where: { tenantId } }); + const orderNumber = `OV-${String(count + 1).padStart(6, '0')}`; + const order = this.orderRepository.create({ ...dto, tenantId, orderNumber, createdBy, orderDate: new Date(), requestedDate: dto.requestedDate ? new Date(dto.requestedDate) : undefined, promisedDate: dto.promisedDate ? new Date(dto.promisedDate) : undefined }); + return this.orderRepository.save(order); + } + + async updateSalesOrder(id: string, tenantId: string, dto: UpdateSalesOrderDto, updatedBy?: string): Promise { + const order = await this.findOrder(id, tenantId); + if (!order) return null; + Object.assign(order, { ...dto, updatedBy }); + return this.orderRepository.save(order); + } + + async deleteSalesOrder(id: string, tenantId: string): Promise { + const result = await this.orderRepository.softDelete({ id, tenantId }); + return (result.affected ?? 0) > 0; + } + + async confirmOrder(id: string, tenantId: string, userId?: string): Promise { + const order = await this.findOrder(id, tenantId); + if (!order || order.status !== 'draft') return null; + order.status = 'confirmed'; + order.updatedBy = userId; + return this.orderRepository.save(order); + } + + async shipOrder(id: string, tenantId: string, trackingNumber?: string, carrier?: string, userId?: string): Promise { + const order = await this.findOrder(id, tenantId); + if (!order || !['confirmed', 'processing'].includes(order.status)) return null; + order.status = 'shipped'; + order.shippedDate = new Date(); + if (trackingNumber) order.trackingNumber = trackingNumber; + if (carrier) order.carrier = carrier; + order.updatedBy = userId; + return this.orderRepository.save(order); + } + + async deliverOrder(id: string, tenantId: string, userId?: string): Promise { + const order = await this.findOrder(id, tenantId); + if (!order || order.status !== 'shipped') return null; + order.status = 'delivered'; + order.deliveredDate = new Date(); + order.updatedBy = userId; + return this.orderRepository.save(order); + } +} + +export { SalesService as default }; diff --git a/src/modules/storage/controllers/index.ts b/src/modules/storage/controllers/index.ts new file mode 100644 index 0000000..159a806 --- /dev/null +++ b/src/modules/storage/controllers/index.ts @@ -0,0 +1 @@ +export { StorageController } from './storage.controller'; diff --git a/src/modules/storage/controllers/storage.controller.ts b/src/modules/storage/controllers/storage.controller.ts new file mode 100644 index 0000000..5429b1a --- /dev/null +++ b/src/modules/storage/controllers/storage.controller.ts @@ -0,0 +1,358 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { StorageService, FileSearchFilters } from '../services/storage.service'; + +export class StorageController { + public router: Router; + + constructor(private readonly storageService: StorageService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Buckets + this.router.get('/buckets', this.findAllBuckets.bind(this)); + this.router.get('/buckets/:id', this.findBucket.bind(this)); + this.router.post('/buckets', this.createBucket.bind(this)); + this.router.patch('/buckets/:id', this.updateBucket.bind(this)); + this.router.delete('/buckets/:id', this.deleteBucket.bind(this)); + + // Folders + this.router.get('/buckets/:bucketId/folders', this.findFoldersInBucket.bind(this)); + this.router.get('/folders/:id', this.findFolder.bind(this)); + this.router.post('/folders', this.createFolder.bind(this)); + this.router.patch('/folders/:id', this.updateFolder.bind(this)); + this.router.delete('/folders/:id', this.deleteFolder.bind(this)); + + // Files + this.router.get('/buckets/:bucketId/files', this.findFilesInFolder.bind(this)); + this.router.get('/files/:id', this.findFile.bind(this)); + this.router.get('/files/key/:storageKey', this.findFileByStorageKey.bind(this)); + this.router.post('/files', this.createFile.bind(this)); + this.router.patch('/files/:id', this.updateFile.bind(this)); + this.router.delete('/files/:id', this.deleteFile.bind(this)); + this.router.post('/files/:id/download', this.incrementDownloadCount.bind(this)); + + // Search & Stats + this.router.get('/search', this.searchFiles.bind(this)); + this.router.get('/usage', this.getStorageUsage.bind(this)); + this.router.get('/recent', this.findRecentFiles.bind(this)); + } + + // ============================================ + // BUCKETS + // ============================================ + + private async findAllBuckets(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const buckets = await this.storageService.findAllBuckets(tenantId); + res.json({ data: buckets, total: buckets.length }); + } catch (error) { + next(error); + } + } + + private async findBucket(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const bucket = await this.storageService.findBucket(id); + + if (!bucket) { + res.status(404).json({ error: 'Bucket not found' }); + return; + } + + res.json({ data: bucket }); + } catch (error) { + next(error); + } + } + + private async createBucket(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const bucket = await this.storageService.createBucket(tenantId, req.body, userId); + res.status(201).json({ data: bucket }); + } catch (error) { + next(error); + } + } + + private async updateBucket(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const bucket = await this.storageService.updateBucket(id, req.body); + + if (!bucket) { + res.status(404).json({ error: 'Bucket not found' }); + return; + } + + res.json({ data: bucket }); + } catch (error) { + next(error); + } + } + + private async deleteBucket(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deleted = await this.storageService.deleteBucket(id); + + if (!deleted) { + res.status(404).json({ error: 'Bucket not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // FOLDERS + // ============================================ + + private async findFoldersInBucket(req: Request, res: Response, next: NextFunction): Promise { + try { + const { bucketId } = req.params; + const parentId = req.query.parentId as string | undefined; + + const folders = await this.storageService.findFoldersInBucket(bucketId, parentId); + res.json({ data: folders, total: folders.length }); + } catch (error) { + next(error); + } + } + + private async findFolder(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const folder = await this.storageService.findFolder(id); + + if (!folder) { + res.status(404).json({ error: 'Folder not found' }); + return; + } + + res.json({ data: folder }); + } catch (error) { + next(error); + } + } + + private async createFolder(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const { bucketId, ...data } = req.body; + + const folder = await this.storageService.createFolder(tenantId, bucketId, data, userId); + res.status(201).json({ data: folder }); + } catch (error) { + next(error); + } + } + + private async updateFolder(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const folder = await this.storageService.updateFolder(id, req.body); + + if (!folder) { + res.status(404).json({ error: 'Folder not found' }); + return; + } + + res.json({ data: folder }); + } catch (error) { + next(error); + } + } + + private async deleteFolder(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const recursive = req.query.recursive === 'true'; + + const deleted = await this.storageService.deleteFolder(id, recursive); + + if (!deleted) { + res.status(404).json({ error: 'Folder not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ============================================ + // FILES + // ============================================ + + private async findFilesInFolder(req: Request, res: Response, next: NextFunction): Promise { + try { + const { bucketId } = req.params; + const folderId = req.query.folderId as string | undefined; + const filters: FileSearchFilters = { + category: req.query.category as string, + mimeType: req.query.mimeType as string, + uploadedBy: req.query.uploadedBy as string, + }; + + if (req.query.isPublic !== undefined) { + filters.isPublic = req.query.isPublic === 'true'; + } + + const files = await this.storageService.findFilesInFolder(bucketId, folderId, filters); + res.json({ data: files, total: files.length }); + } catch (error) { + next(error); + } + } + + private async findFile(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const file = await this.storageService.findFile(id); + + if (!file) { + res.status(404).json({ error: 'File not found' }); + return; + } + + res.json({ data: file }); + } catch (error) { + next(error); + } + } + + private async findFileByStorageKey(req: Request, res: Response, next: NextFunction): Promise { + try { + const { storageKey } = req.params; + const file = await this.storageService.findFileByStorageKey(storageKey); + + if (!file) { + res.status(404).json({ error: 'File not found' }); + return; + } + + res.json({ data: file }); + } catch (error) { + next(error); + } + } + + private async createFile(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + const { bucketId, ...data } = req.body; + + const file = await this.storageService.createFile(tenantId, bucketId, data, userId); + res.status(201).json({ data: file }); + } catch (error) { + next(error); + } + } + + private async updateFile(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const file = await this.storageService.updateFile(id, req.body); + + if (!file) { + res.status(404).json({ error: 'File not found' }); + return; + } + + res.json({ data: file }); + } catch (error) { + next(error); + } + } + + private async deleteFile(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deleted = await this.storageService.deleteFile(id); + + if (!deleted) { + res.status(404).json({ error: 'File not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async incrementDownloadCount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + await this.storageService.incrementDownloadCount(id); + res.json({ data: { success: true } }); + } catch (error) { + next(error); + } + } + + // ============================================ + // SEARCH & STATS + // ============================================ + + private async searchFiles(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const query = req.query.q as string; + + if (!query) { + res.status(400).json({ error: 'Query parameter "q" is required' }); + return; + } + + const filters: FileSearchFilters = { + category: req.query.category as string, + mimeType: req.query.mimeType as string, + }; + + if (req.query.isPublic !== undefined) { + filters.isPublic = req.query.isPublic === 'true'; + } + + const files = await this.storageService.searchFiles(tenantId, query, filters); + res.json({ data: files, total: files.length }); + } catch (error) { + next(error); + } + } + + private async getStorageUsage(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const usage = await this.storageService.getStorageUsage(tenantId); + res.json({ data: usage }); + } catch (error) { + next(error); + } + } + + private async findRecentFiles(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const limit = parseInt(req.query.limit as string) || 20; + + const files = await this.storageService.findRecentFiles(tenantId, limit); + res.json({ data: files, total: files.length }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/storage/dto/index.ts b/src/modules/storage/dto/index.ts new file mode 100644 index 0000000..28df792 --- /dev/null +++ b/src/modules/storage/dto/index.ts @@ -0,0 +1,10 @@ +export { + CreateBucketDto, + UpdateBucketDto, + CreateFolderDto, + UpdateFolderDto, + CreateFileDto, + UpdateFileDto, + UpdateProcessingStatusDto, + SearchFilesDto, +} from './storage.dto'; diff --git a/src/modules/storage/dto/storage.dto.ts b/src/modules/storage/dto/storage.dto.ts new file mode 100644 index 0000000..39ecc31 --- /dev/null +++ b/src/modules/storage/dto/storage.dto.ts @@ -0,0 +1,286 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsObject, + IsUUID, + MaxLength, + MinLength, + Min, +} from 'class-validator'; + +// ============================================ +// BUCKET DTOs +// ============================================ + +export class CreateBucketDto { + @IsString() + @MinLength(2) + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + bucketType?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + provider?: string; + + @IsOptional() + @IsBoolean() + isPublic?: boolean; + + @IsOptional() + @IsNumber() + @Min(0) + maxSizeBytes?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxFileSize?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedMimeTypes?: string[]; + + @IsOptional() + @IsObject() + retentionPolicy?: Record; + + @IsOptional() + @IsObject() + providerConfig?: Record; +} + +export class UpdateBucketDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + isPublic?: boolean; + + @IsOptional() + @IsNumber() + @Min(0) + maxSizeBytes?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxFileSize?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedMimeTypes?: string[]; + + @IsOptional() + @IsObject() + retentionPolicy?: Record; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +// ============================================ +// FOLDER DTOs +// ============================================ + +export class CreateFolderDto { + @IsUUID() + bucketId: string; + + @IsString() + @MinLength(1) + @MaxLength(200) + name: string; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + color?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class UpdateFolderDto { + @IsOptional() + @IsString() + @MaxLength(200) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + color?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + icon?: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +// ============================================ +// FILE DTOs +// ============================================ + +export class CreateFileDto { + @IsUUID() + bucketId: string; + + @IsOptional() + @IsUUID() + folderId?: string; + + @IsString() + @MaxLength(500) + originalName: string; + + @IsString() + @MaxLength(500) + storageKey: string; + + @IsString() + @MaxLength(100) + mimeType: string; + + @IsNumber() + @Min(0) + sizeBytes: number; + + @IsOptional() + @IsString() + @MaxLength(64) + checksum?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + category?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + isPublic?: boolean; + + @IsOptional() + @IsObject() + metadata?: Record; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; +} + +export class UpdateFileDto { + @IsOptional() + @IsString() + @MaxLength(500) + originalName?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + category?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + isPublic?: boolean; + + @IsOptional() + @IsObject() + metadata?: Record; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsUUID() + folderId?: string; +} + +export class UpdateProcessingStatusDto { + @IsString() + @MaxLength(20) + status: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +// ============================================ +// SEARCH DTOs +// ============================================ + +export class SearchFilesDto { + @IsString() + @MinLength(1) + query: string; + + @IsOptional() + @IsString() + @MaxLength(30) + category?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + mimeType?: string; + + @IsOptional() + @IsBoolean() + isPublic?: boolean; +} diff --git a/src/modules/storage/entities/bucket.entity.ts b/src/modules/storage/entities/bucket.entity.ts new file mode 100644 index 0000000..2d2a5bb --- /dev/null +++ b/src/modules/storage/entities/bucket.entity.ts @@ -0,0 +1,66 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type BucketType = 'public' | 'private' | 'protected'; +export type StorageProvider = 'local' | 's3' | 'gcs' | 'azure'; + +@Entity({ name: 'buckets', schema: 'storage' }) +export class StorageBucket { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index({ unique: true }) + @Column({ name: 'name', type: 'varchar', length: 100 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'bucket_type', type: 'varchar', length: 30, default: 'private' }) + bucketType: BucketType; + + @Column({ name: 'max_file_size_mb', type: 'int', default: 50 }) + maxFileSizeMb: number; + + @Column({ name: 'allowed_mime_types', type: 'text', array: true, default: [] }) + allowedMimeTypes: string[]; + + @Column({ name: 'allowed_extensions', type: 'text', array: true, default: [] }) + allowedExtensions: string[]; + + @Column({ name: 'auto_delete_days', type: 'int', nullable: true }) + autoDeleteDays: number; + + @Column({ name: 'versioning_enabled', type: 'boolean', default: false }) + versioningEnabled: boolean; + + @Column({ name: 'max_versions', type: 'int', default: 5 }) + maxVersions: number; + + @Column({ name: 'storage_provider', type: 'varchar', length: 30, default: 'local' }) + storageProvider: StorageProvider; + + @Column({ name: 'storage_config', type: 'jsonb', default: {} }) + storageConfig: Record; + + @Column({ name: 'quota_per_tenant_gb', type: 'int', nullable: true }) + quotaPerTenantGb: number; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_system', type: 'boolean', default: false }) + isSystem: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/storage/entities/file-access-token.entity.ts b/src/modules/storage/entities/file-access-token.entity.ts new file mode 100644 index 0000000..30195de --- /dev/null +++ b/src/modules/storage/entities/file-access-token.entity.ts @@ -0,0 +1,63 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { StorageFile } from './file.entity'; + +@Entity({ name: 'file_access_tokens', schema: 'storage' }) +export class FileAccessToken { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'file_id', type: 'uuid' }) + fileId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'token', type: 'varchar', length: 255, unique: true }) + token: string; + + @Column({ name: 'permissions', type: 'text', array: true, default: ['read'] }) + permissions: string[]; + + @Column({ name: 'allowed_ips', type: 'inet', array: true, nullable: true }) + allowedIps: string[]; + + @Column({ name: 'max_downloads', type: 'int', nullable: true }) + maxDownloads: number; + + @Column({ name: 'download_count', type: 'int', default: 0 }) + downloadCount: number; + + @Index() + @Column({ name: 'expires_at', type: 'timestamptz' }) + expiresAt: Date; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt: Date; + + @Column({ name: 'created_for', type: 'varchar', length: 255, nullable: true }) + createdFor: string; + + @Column({ name: 'purpose', type: 'text', nullable: true }) + purpose: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => StorageFile, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'file_id' }) + file: StorageFile; +} diff --git a/src/modules/storage/entities/file-share.entity.ts b/src/modules/storage/entities/file-share.entity.ts new file mode 100644 index 0000000..0ca769d --- /dev/null +++ b/src/modules/storage/entities/file-share.entity.ts @@ -0,0 +1,88 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { StorageFile } from './file.entity'; + +@Entity({ name: 'file_shares', schema: 'storage' }) +export class FileShare { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'file_id', type: 'uuid' }) + fileId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'shared_with_user_id', type: 'uuid', nullable: true }) + sharedWithUserId: string; + + @Column({ name: 'shared_with_email', type: 'varchar', length: 255, nullable: true }) + sharedWithEmail: string; + + @Column({ name: 'shared_with_role', type: 'varchar', length: 50, nullable: true }) + sharedWithRole: string; + + @Column({ name: 'can_view', type: 'boolean', default: true }) + canView: boolean; + + @Column({ name: 'can_download', type: 'boolean', default: true }) + canDownload: boolean; + + @Column({ name: 'can_edit', type: 'boolean', default: false }) + canEdit: boolean; + + @Column({ name: 'can_delete', type: 'boolean', default: false }) + canDelete: boolean; + + @Column({ name: 'can_share', type: 'boolean', default: false }) + canShare: boolean; + + @Index() + @Column({ name: 'public_link', type: 'varchar', length: 255, unique: true, nullable: true }) + publicLink: string; + + @Column({ name: 'public_link_password', type: 'varchar', length: 255, nullable: true }) + publicLinkPassword: string; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true }) + revokedAt: Date; + + @Column({ name: 'view_count', type: 'int', default: 0 }) + viewCount: number; + + @Column({ name: 'download_count', type: 'int', default: 0 }) + downloadCount: number; + + @Column({ name: 'last_accessed_at', type: 'timestamptz', nullable: true }) + lastAccessedAt: Date; + + @Column({ name: 'notify_on_access', type: 'boolean', default: false }) + notifyOnAccess: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => StorageFile, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'file_id' }) + file: StorageFile; +} diff --git a/src/modules/storage/entities/file.entity.ts b/src/modules/storage/entities/file.entity.ts new file mode 100644 index 0000000..193b473 --- /dev/null +++ b/src/modules/storage/entities/file.entity.ts @@ -0,0 +1,154 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { StorageBucket } from './bucket.entity'; +import { StorageFolder } from './folder.entity'; + +export type FileCategory = 'image' | 'document' | 'video' | 'audio' | 'archive' | 'other'; +export type FileStatus = 'active' | 'processing' | 'archived' | 'deleted'; +export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed'; + +@Entity({ name: 'files', schema: 'storage' }) +@Unique(['tenantId', 'bucketId', 'path', 'version']) +export class StorageFile { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'bucket_id', type: 'uuid' }) + bucketId: string; + + @Index() + @Column({ name: 'folder_id', type: 'uuid', nullable: true }) + folderId: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'original_name', type: 'varchar', length: 255 }) + originalName: string; + + @Column({ name: 'path', type: 'text' }) + path: string; + + @Index() + @Column({ name: 'mime_type', type: 'varchar', length: 100 }) + mimeType: string; + + @Column({ name: 'extension', type: 'varchar', length: 20, nullable: true }) + extension: string; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 30, nullable: true }) + category: FileCategory; + + @Column({ name: 'size_bytes', type: 'bigint' }) + sizeBytes: number; + + @Column({ name: 'checksum_md5', type: 'varchar', length: 32, nullable: true }) + checksumMd5: string; + + @Index() + @Column({ name: 'checksum_sha256', type: 'varchar', length: 64, nullable: true }) + checksumSha256: string; + + @Column({ name: 'storage_key', type: 'text' }) + storageKey: string; + + @Column({ name: 'storage_url', type: 'text', nullable: true }) + storageUrl: string; + + @Column({ name: 'cdn_url', type: 'text', nullable: true }) + cdnUrl: string; + + @Column({ name: 'width', type: 'int', nullable: true }) + width: number; + + @Column({ name: 'height', type: 'int', nullable: true }) + height: number; + + @Column({ name: 'thumbnail_url', type: 'text', nullable: true }) + thumbnailUrl: string; + + @Column({ name: 'thumbnails', type: 'jsonb', default: {} }) + thumbnails: Record; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'alt_text', type: 'text', nullable: true }) + altText: string; + + @Column({ name: 'version', type: 'int', default: 1 }) + version: number; + + @Column({ name: 'parent_version_id', type: 'uuid', nullable: true }) + parentVersionId: string; + + @Column({ name: 'is_latest', type: 'boolean', default: true }) + isLatest: boolean; + + @Index() + @Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true }) + entityType: string; + + @Column({ name: 'entity_id', type: 'uuid', nullable: true }) + entityId: string; + + @Column({ name: 'is_public', type: 'boolean', default: false }) + isPublic: boolean; + + @Column({ name: 'access_count', type: 'int', default: 0 }) + accessCount: number; + + @Column({ name: 'last_accessed_at', type: 'timestamptz', nullable: true }) + lastAccessedAt: Date; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'active' }) + status: FileStatus; + + @Column({ name: 'archived_at', type: 'timestamptz', nullable: true }) + archivedAt: Date; + + @Column({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; + + @Column({ name: 'processing_status', type: 'varchar', length: 20, nullable: true }) + processingStatus: ProcessingStatus; + + @Column({ name: 'processing_error', type: 'text', nullable: true }) + processingError: string; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'uploaded_by', type: 'uuid', nullable: true }) + uploadedBy: string; + + @ManyToOne(() => StorageBucket, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bucket_id' }) + bucket: StorageBucket; + + @ManyToOne(() => StorageFolder, { onDelete: 'SET NULL' }) + @JoinColumn({ name: 'folder_id' }) + folder: StorageFolder; +} diff --git a/src/modules/storage/entities/folder.entity.ts b/src/modules/storage/entities/folder.entity.ts new file mode 100644 index 0000000..ce3fdae --- /dev/null +++ b/src/modules/storage/entities/folder.entity.ts @@ -0,0 +1,83 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + OneToMany, + Unique, +} from 'typeorm'; +import { StorageBucket } from './bucket.entity'; + +@Entity({ name: 'folders', schema: 'storage' }) +@Unique(['tenantId', 'bucketId', 'path']) +export class StorageFolder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'bucket_id', type: 'uuid' }) + bucketId: string; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string; + + @Index() + @Column({ name: 'path', type: 'text' }) + path: string; + + @Column({ name: 'name', type: 'varchar', length: 255 }) + name: string; + + @Column({ name: 'depth', type: 'int', default: 0 }) + depth: number; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'color', type: 'varchar', length: 7, nullable: true }) + color: string; + + @Column({ name: 'icon', type: 'varchar', length: 50, nullable: true }) + icon: string; + + @Column({ name: 'is_private', type: 'boolean', default: false }) + isPrivate: boolean; + + @Column({ name: 'owner_id', type: 'uuid', nullable: true }) + ownerId: string; + + @Column({ name: 'file_count', type: 'int', default: 0 }) + fileCount: number; + + @Column({ name: 'total_size_bytes', type: 'bigint', default: 0 }) + totalSizeBytes: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => StorageBucket, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bucket_id' }) + bucket: StorageBucket; + + @ManyToOne(() => StorageFolder, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'parent_id' }) + parent: StorageFolder; + + @OneToMany(() => StorageFolder, (folder) => folder.parent) + children: StorageFolder[]; +} diff --git a/src/modules/storage/entities/index.ts b/src/modules/storage/entities/index.ts new file mode 100644 index 0000000..e6f07f0 --- /dev/null +++ b/src/modules/storage/entities/index.ts @@ -0,0 +1,7 @@ +export { StorageBucket, BucketType, StorageProvider } from './bucket.entity'; +export { StorageFolder } from './folder.entity'; +export { StorageFile, FileCategory, FileStatus, ProcessingStatus } from './file.entity'; +export { FileAccessToken } from './file-access-token.entity'; +export { StorageUpload, UploadStatus } from './upload.entity'; +export { FileShare } from './file-share.entity'; +export { TenantUsage } from './tenant-usage.entity'; diff --git a/src/modules/storage/entities/tenant-usage.entity.ts b/src/modules/storage/entities/tenant-usage.entity.ts new file mode 100644 index 0000000..f02517a --- /dev/null +++ b/src/modules/storage/entities/tenant-usage.entity.ts @@ -0,0 +1,57 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { StorageBucket } from './bucket.entity'; + +@Entity({ name: 'tenant_usage', schema: 'storage' }) +@Unique(['tenantId', 'bucketId', 'monthYear']) +export class TenantUsage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'bucket_id', type: 'uuid' }) + bucketId: string; + + @Column({ name: 'file_count', type: 'int', default: 0 }) + fileCount: number; + + @Column({ name: 'total_size_bytes', type: 'bigint', default: 0 }) + totalSizeBytes: number; + + @Column({ name: 'quota_bytes', type: 'bigint', nullable: true }) + quotaBytes: number; + + @Column({ name: 'quota_file_count', type: 'int', nullable: true }) + quotaFileCount: number; + + @Column({ name: 'usage_by_category', type: 'jsonb', default: {} }) + usageByCategory: Record; + + @Column({ name: 'monthly_upload_bytes', type: 'bigint', default: 0 }) + monthlyUploadBytes: number; + + @Column({ name: 'monthly_download_bytes', type: 'bigint', default: 0 }) + monthlyDownloadBytes: number; + + @Column({ name: 'month_year', type: 'varchar', length: 7 }) + monthYear: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => StorageBucket, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bucket_id' }) + bucket: StorageBucket; +} diff --git a/src/modules/storage/entities/upload.entity.ts b/src/modules/storage/entities/upload.entity.ts new file mode 100644 index 0000000..e095875 --- /dev/null +++ b/src/modules/storage/entities/upload.entity.ts @@ -0,0 +1,102 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { StorageBucket } from './bucket.entity'; +import { StorageFolder } from './folder.entity'; +import { StorageFile } from './file.entity'; + +export type UploadStatus = 'pending' | 'uploading' | 'processing' | 'completed' | 'failed' | 'cancelled'; + +@Entity({ name: 'uploads', schema: 'storage' }) +export class StorageUpload { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'bucket_id', type: 'uuid' }) + bucketId: string; + + @Column({ name: 'folder_id', type: 'uuid', nullable: true }) + folderId: string; + + @Column({ name: 'file_name', type: 'varchar', length: 255 }) + fileName: string; + + @Column({ name: 'mime_type', type: 'varchar', length: 100, nullable: true }) + mimeType: string; + + @Column({ name: 'total_size_bytes', type: 'bigint', nullable: true }) + totalSizeBytes: number; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: UploadStatus; + + @Column({ name: 'uploaded_bytes', type: 'bigint', default: 0 }) + uploadedBytes: number; + + @Column({ name: 'upload_progress', type: 'decimal', precision: 5, scale: 2, default: 0 }) + uploadProgress: number; + + @Column({ name: 'total_chunks', type: 'int', nullable: true }) + totalChunks: number; + + @Column({ name: 'completed_chunks', type: 'int', default: 0 }) + completedChunks: number; + + @Column({ name: 'chunk_size_bytes', type: 'int', nullable: true }) + chunkSizeBytes: number; + + @Column({ name: 'chunks_status', type: 'jsonb', default: {} }) + chunksStatus: Record; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Column({ name: 'file_id', type: 'uuid', nullable: true }) + fileId: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'started_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + startedAt: Date; + + @Column({ name: 'last_chunk_at', type: 'timestamptz', nullable: true }) + lastChunkAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Index() + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => StorageBucket, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'bucket_id' }) + bucket: StorageBucket; + + @ManyToOne(() => StorageFolder, { nullable: true }) + @JoinColumn({ name: 'folder_id' }) + folder: StorageFolder; + + @ManyToOne(() => StorageFile, { nullable: true }) + @JoinColumn({ name: 'file_id' }) + file: StorageFile; +} diff --git a/src/modules/storage/index.ts b/src/modules/storage/index.ts new file mode 100644 index 0000000..f55c5c9 --- /dev/null +++ b/src/modules/storage/index.ts @@ -0,0 +1,5 @@ +export { StorageModule, StorageModuleOptions } from './storage.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/storage/services/index.ts b/src/modules/storage/services/index.ts new file mode 100644 index 0000000..36fd7d0 --- /dev/null +++ b/src/modules/storage/services/index.ts @@ -0,0 +1 @@ +export { StorageService, FileSearchFilters } from './storage.service'; diff --git a/src/modules/storage/services/storage.service.ts b/src/modules/storage/services/storage.service.ts new file mode 100644 index 0000000..766c7c2 --- /dev/null +++ b/src/modules/storage/services/storage.service.ts @@ -0,0 +1,332 @@ +import { Repository, FindOptionsWhere, In, IsNull } from 'typeorm'; +import { StorageBucket, StorageFolder, StorageFile } from '../entities'; + +export interface FileSearchFilters { + category?: string; + mimeType?: string; + folderId?: string; + isPublic?: boolean; + uploadedBy?: string; +} + +export class StorageService { + constructor( + private readonly bucketRepository: Repository, + private readonly folderRepository: Repository, + private readonly fileRepository: Repository + ) {} + + // ============================================ + // BUCKETS + // ============================================ + + async findAllBuckets(tenantId: string): Promise { + return this.bucketRepository.find({ + where: { tenantId }, + order: { name: 'ASC' }, + }); + } + + async findBucket(id: string): Promise { + return this.bucketRepository.findOne({ where: { id } }); + } + + async findBucketByName(tenantId: string, name: string): Promise { + return this.bucketRepository.findOne({ where: { tenantId, name } }); + } + + async createBucket( + tenantId: string, + data: Partial, + createdBy?: string + ): Promise { + const bucket = this.bucketRepository.create({ + ...data, + tenantId, + createdBy, + }); + return this.bucketRepository.save(bucket); + } + + async updateBucket(id: string, data: Partial): Promise { + const bucket = await this.findBucket(id); + if (!bucket) return null; + + Object.assign(bucket, data); + return this.bucketRepository.save(bucket); + } + + async deleteBucket(id: string): Promise { + // Check if bucket has files + const fileCount = await this.fileRepository.count({ where: { bucketId: id } }); + if (fileCount > 0) { + throw new Error('Cannot delete bucket with files. Delete files first.'); + } + + const result = await this.bucketRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async updateBucketUsage(id: string, sizeChange: number, fileCountChange: number): Promise { + await this.bucketRepository + .createQueryBuilder() + .update() + .set({ + currentSizeBytes: () => `current_size_bytes + ${sizeChange}`, + fileCount: () => `file_count + ${fileCountChange}`, + }) + .where('id = :id', { id }) + .execute(); + } + + // ============================================ + // FOLDERS + // ============================================ + + async findFoldersInBucket(bucketId: string, parentId?: string): Promise { + const where: FindOptionsWhere = { bucketId }; + if (parentId) { + where.parentId = parentId; + } else { + where.parentId = IsNull(); + } + + return this.folderRepository.find({ + where, + order: { name: 'ASC' }, + }); + } + + async findFolder(id: string): Promise { + return this.folderRepository.findOne({ where: { id } }); + } + + async findFolderByPath(bucketId: string, path: string): Promise { + return this.folderRepository.findOne({ where: { bucketId, path } }); + } + + async createFolder( + tenantId: string, + bucketId: string, + data: Partial, + createdBy?: string + ): Promise { + const folder = this.folderRepository.create({ + ...data, + tenantId, + bucketId, + createdBy, + }); + return this.folderRepository.save(folder); + } + + async updateFolder(id: string, data: Partial): Promise { + const folder = await this.findFolder(id); + if (!folder) return null; + + Object.assign(folder, data); + return this.folderRepository.save(folder); + } + + async deleteFolder(id: string, recursive: boolean = false): Promise { + const folder = await this.findFolder(id); + if (!folder) return false; + + // Check for children + const childFolders = await this.folderRepository.count({ where: { parentId: id } }); + const childFiles = await this.fileRepository.count({ where: { folderId: id } }); + + if ((childFolders > 0 || childFiles > 0) && !recursive) { + throw new Error('Folder is not empty. Use recursive delete or remove contents first.'); + } + + if (recursive) { + // Delete all files in folder + await this.fileRepository.softDelete({ folderId: id }); + // Delete all subfolders (recursive deletion would need proper implementation) + await this.folderRepository.softDelete({ parentId: id }); + } + + const result = await this.folderRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + // ============================================ + // FILES + // ============================================ + + async findFilesInFolder( + bucketId: string, + folderId?: string, + filters: FileSearchFilters = {} + ): Promise { + const where: FindOptionsWhere = { bucketId }; + + if (folderId) { + where.folderId = folderId; + } else { + where.folderId = IsNull(); + } + + if (filters.category) where.category = filters.category as any; + if (filters.mimeType) where.mimeType = filters.mimeType; + if (filters.isPublic !== undefined) where.isPublic = filters.isPublic; + if (filters.uploadedBy) where.uploadedBy = filters.uploadedBy; + + return this.fileRepository.find({ + where, + order: { originalName: 'ASC' }, + }); + } + + async findFile(id: string): Promise { + return this.fileRepository.findOne({ + where: { id }, + relations: ['bucket', 'folder'], + }); + } + + async findFileByStorageKey(storageKey: string): Promise { + return this.fileRepository.findOne({ where: { storageKey } }); + } + + async createFile( + tenantId: string, + bucketId: string, + data: Partial, + uploadedBy?: string + ): Promise { + const file = this.fileRepository.create({ + ...data, + tenantId, + bucketId, + uploadedBy, + status: 'active', + }); + + const savedFile = await this.fileRepository.save(file); + + // Update bucket usage + await this.updateBucketUsage(bucketId, data.sizeBytes || 0, 1); + + return savedFile; + } + + async updateFile(id: string, data: Partial): Promise { + const file = await this.findFile(id); + if (!file) return null; + + Object.assign(file, data); + return this.fileRepository.save(file); + } + + async deleteFile(id: string): Promise { + const file = await this.findFile(id); + if (!file) return false; + + // Update bucket usage + await this.updateBucketUsage(file.bucketId, -(file.sizeBytes || 0), -1); + + const result = await this.fileRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async incrementDownloadCount(id: string): Promise { + await this.fileRepository + .createQueryBuilder() + .update() + .set({ + downloadCount: () => 'download_count + 1', + lastAccessedAt: new Date(), + }) + .where('id = :id', { id }) + .execute(); + } + + async updateProcessingStatus( + id: string, + status: string, + metadata?: Record + ): Promise { + const updates: Partial = { processingStatus: status as any }; + if (metadata) { + updates.metadata = metadata; + } + if (status === 'completed') { + updates.processedAt = new Date(); + } + await this.fileRepository.update(id, updates); + } + + // ============================================ + // SEARCH & QUERIES + // ============================================ + + async searchFiles( + tenantId: string, + query: string, + filters: FileSearchFilters = {} + ): Promise { + const qb = this.fileRepository + .createQueryBuilder('file') + .where('file.tenant_id = :tenantId', { tenantId }) + .andWhere( + '(file.original_name ILIKE :query OR file.description ILIKE :query)', + { query: `%${query}%` } + ); + + if (filters.category) { + qb.andWhere('file.category = :category', { category: filters.category }); + } + if (filters.mimeType) { + qb.andWhere('file.mime_type = :mimeType', { mimeType: filters.mimeType }); + } + if (filters.isPublic !== undefined) { + qb.andWhere('file.is_public = :isPublic', { isPublic: filters.isPublic }); + } + + return qb.orderBy('file.created_at', 'DESC').take(100).getMany(); + } + + async getStorageUsage(tenantId: string): Promise<{ + totalBytes: number; + fileCount: number; + byCategory: Record; + }> { + const buckets = await this.bucketRepository.find({ where: { tenantId } }); + + let totalBytes = 0; + let fileCount = 0; + for (const bucket of buckets) { + totalBytes += bucket.currentSizeBytes || 0; + fileCount += bucket.fileCount || 0; + } + + const categoryStats = await this.fileRepository + .createQueryBuilder('file') + .select('file.category', 'category') + .addSelect('SUM(file.size_bytes)', 'bytes') + .addSelect('COUNT(*)', 'count') + .where('file.tenant_id = :tenantId', { tenantId }) + .groupBy('file.category') + .getRawMany(); + + const byCategory: Record = {}; + for (const stat of categoryStats) { + byCategory[stat.category || 'uncategorized'] = { + bytes: parseInt(stat.bytes) || 0, + count: parseInt(stat.count) || 0, + }; + } + + return { totalBytes, fileCount, byCategory }; + } + + async findRecentFiles(tenantId: string, limit: number = 20): Promise { + return this.fileRepository.find({ + where: { tenantId }, + order: { createdAt: 'DESC' }, + take: limit, + }); + } +} diff --git a/src/modules/storage/storage.module.ts b/src/modules/storage/storage.module.ts new file mode 100644 index 0000000..37a107e --- /dev/null +++ b/src/modules/storage/storage.module.ts @@ -0,0 +1,54 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { StorageService } from './services'; +import { StorageController } from './controllers'; +import { + StorageBucket, + StorageFolder, + StorageFile, +} from './entities'; + +export interface StorageModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class StorageModule { + public router: Router; + public storageService: StorageService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: StorageModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const bucketRepository = this.dataSource.getRepository(StorageBucket); + const folderRepository = this.dataSource.getRepository(StorageFolder); + const fileRepository = this.dataSource.getRepository(StorageFile); + + this.storageService = new StorageService( + bucketRepository, + folderRepository, + fileRepository + ); + } + + private initializeRoutes(): void { + const storageController = new StorageController(this.storageService); + this.router.use(`${this.basePath}/storage`, storageController.router); + } + + static getEntities(): Function[] { + return [ + StorageBucket, + StorageFolder, + StorageFile, + ]; + } +} 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/modules/warehouses/controllers/index.ts b/src/modules/warehouses/controllers/index.ts new file mode 100644 index 0000000..bb75c7b --- /dev/null +++ b/src/modules/warehouses/controllers/index.ts @@ -0,0 +1 @@ +export { WarehousesController } from './warehouses.controller'; diff --git a/src/modules/warehouses/controllers/warehouses.controller.ts b/src/modules/warehouses/controllers/warehouses.controller.ts new file mode 100644 index 0000000..de7579a --- /dev/null +++ b/src/modules/warehouses/controllers/warehouses.controller.ts @@ -0,0 +1,313 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { WarehousesService } from '../services/warehouses.service'; +import { + CreateWarehouseDto, + UpdateWarehouseDto, + CreateWarehouseLocationDto, + UpdateWarehouseLocationDto, +} from '../dto'; + +export class WarehousesController { + public router: Router; + + constructor(private readonly warehousesService: WarehousesService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Warehouses + this.router.get('/', this.findAll.bind(this)); + this.router.get('/active', this.getActiveWarehouses.bind(this)); + this.router.get('/default', this.getDefaultWarehouse.bind(this)); + this.router.get('/:id', this.findOne.bind(this)); + this.router.get('/code/:code', this.findByCode.bind(this)); + this.router.post('/', this.create.bind(this)); + this.router.patch('/:id', this.update.bind(this)); + this.router.delete('/:id', this.delete.bind(this)); + + // Locations + this.router.get('/:id/locations', this.getLocations.bind(this)); + this.router.get('/:id/locations/tree', this.getLocationTree.bind(this)); + this.router.get('/:id/locations/pickable', this.getPickableLocations.bind(this)); + this.router.get('/:id/locations/receivable', this.getReceivableLocations.bind(this)); + this.router.post('/:id/locations', this.createLocation.bind(this)); + this.router.patch('/:id/locations/:locationId', this.updateLocation.bind(this)); + this.router.delete('/:id/locations/:locationId', this.deleteLocation.bind(this)); + } + + // ==================== Warehouses ==================== + + private async findAll(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { search, warehouseType, branchId, isActive, limit, offset } = req.query; + + const result = await this.warehousesService.findAll({ + tenantId, + search: search as string, + warehouseType: warehouseType as 'standard' | 'transit' | 'returns' | 'quarantine' | 'virtual', + branchId: branchId as string, + isActive: isActive ? isActive === 'true' : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async findOne(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const warehouse = await this.warehousesService.findOne(id, tenantId); + + if (!warehouse) { + res.status(404).json({ error: 'Warehouse not found' }); + return; + } + + res.json({ data: warehouse }); + } catch (error) { + next(error); + } + } + + private async findByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { code } = req.params; + const warehouse = await this.warehousesService.findByCode(code, tenantId); + + if (!warehouse) { + res.status(404).json({ error: 'Warehouse not found' }); + return; + } + + res.json({ data: warehouse }); + } catch (error) { + next(error); + } + } + + private async getDefaultWarehouse(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const warehouse = await this.warehousesService.getDefaultWarehouse(tenantId); + + if (!warehouse) { + res.status(404).json({ error: 'No default warehouse found' }); + return; + } + + res.json({ data: warehouse }); + } catch (error) { + next(error); + } + } + + private async getActiveWarehouses(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const warehouses = await this.warehousesService.getActiveWarehouses(tenantId); + res.json({ data: warehouses }); + } catch (error) { + next(error); + } + } + + private async create(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const dto: CreateWarehouseDto = req.body; + const warehouse = await this.warehousesService.create(tenantId, dto, userId); + res.status(201).json({ data: warehouse }); + } catch (error) { + next(error); + } + } + + private async update(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const dto: UpdateWarehouseDto = req.body; + const warehouse = await this.warehousesService.update(id, tenantId, dto, userId); + + if (!warehouse) { + res.status(404).json({ error: 'Warehouse not found' }); + return; + } + + res.json({ data: warehouse }); + } catch (error) { + next(error); + } + } + + private async delete(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + + if (!tenantId) { + res.status(400).json({ error: 'Tenant ID is required' }); + return; + } + + const { id } = req.params; + const deleted = await this.warehousesService.delete(id, tenantId); + + if (!deleted) { + res.status(404).json({ error: 'Warehouse not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + // ==================== Locations ==================== + + private async getLocations(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { search, locationType, parentId, isActive, limit, offset } = req.query; + + const result = await this.warehousesService.findAllLocations({ + warehouseId: id, + search: search as string, + locationType: locationType as 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin', + parentId: parentId as string, + isActive: isActive ? isActive === 'true' : undefined, + limit: limit ? parseInt(limit as string, 10) : undefined, + offset: offset ? parseInt(offset as string, 10) : undefined, + }); + + res.json(result); + } catch (error) { + next(error); + } + } + + private async getLocationTree(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const locations = await this.warehousesService.getLocationTree(id); + res.json({ data: locations }); + } catch (error) { + next(error); + } + } + + private async getPickableLocations(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const locations = await this.warehousesService.getPickableLocations(id); + res.json({ data: locations }); + } catch (error) { + next(error); + } + } + + private async getReceivableLocations( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const { id } = req.params; + const locations = await this.warehousesService.getReceivableLocations(id); + res.json({ data: locations }); + } catch (error) { + next(error); + } + } + + private async createLocation(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const dto: CreateWarehouseLocationDto = { ...req.body, warehouseId: id }; + const location = await this.warehousesService.createLocation(dto); + res.status(201).json({ data: location }); + } catch (error) { + next(error); + } + } + + private async updateLocation(req: Request, res: Response, next: NextFunction): Promise { + try { + const { locationId } = req.params; + const dto: UpdateWarehouseLocationDto = req.body; + const location = await this.warehousesService.updateLocation(locationId, dto); + + if (!location) { + res.status(404).json({ error: 'Location not found' }); + return; + } + + res.json({ data: location }); + } catch (error) { + next(error); + } + } + + private async deleteLocation(req: Request, res: Response, next: NextFunction): Promise { + try { + const { locationId } = req.params; + const deleted = await this.warehousesService.deleteLocation(locationId); + + if (!deleted) { + res.status(404).json({ error: 'Location not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/warehouses/dto/create-warehouse.dto.ts b/src/modules/warehouses/dto/create-warehouse.dto.ts new file mode 100644 index 0000000..297a719 --- /dev/null +++ b/src/modules/warehouses/dto/create-warehouse.dto.ts @@ -0,0 +1,378 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsUUID, + IsArray, + IsObject, + MaxLength, + IsEnum, +} from 'class-validator'; + +export class CreateWarehouseDto { + @IsString() + @MaxLength(20) + code: string; + + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + branchId?: string; + + @IsOptional() + @IsEnum(['standard', 'transit', 'returns', 'quarantine', 'virtual']) + warehouseType?: 'standard' | 'transit' | 'returns' | 'quarantine' | 'virtual'; + + @IsOptional() + @IsString() + @MaxLength(200) + addressLine1?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + addressLine2?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + state?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + postalCode?: string; + + @IsOptional() + @IsString() + @MaxLength(3) + country?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + managerName?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsNumber() + latitude?: number; + + @IsOptional() + @IsNumber() + longitude?: number; + + @IsOptional() + @IsNumber() + capacityUnits?: number; + + @IsOptional() + @IsNumber() + capacityVolume?: number; + + @IsOptional() + @IsNumber() + capacityWeight?: number; + + @IsOptional() + @IsObject() + settings?: { + allowNegative?: boolean; + autoReorder?: boolean; + }; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; +} + +export class UpdateWarehouseDto { + @IsOptional() + @IsString() + @MaxLength(20) + code?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUUID() + branchId?: string; + + @IsOptional() + @IsEnum(['standard', 'transit', 'returns', 'quarantine', 'virtual']) + warehouseType?: 'standard' | 'transit' | 'returns' | 'quarantine' | 'virtual'; + + @IsOptional() + @IsString() + @MaxLength(200) + addressLine1?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + addressLine2?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + state?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + postalCode?: string; + + @IsOptional() + @IsString() + @MaxLength(3) + country?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + managerName?: string; + + @IsOptional() + @IsString() + @MaxLength(30) + phone?: string; + + @IsOptional() + @IsString() + @MaxLength(255) + email?: string; + + @IsOptional() + @IsNumber() + latitude?: number; + + @IsOptional() + @IsNumber() + longitude?: number; + + @IsOptional() + @IsNumber() + capacityUnits?: number; + + @IsOptional() + @IsNumber() + capacityVolume?: number; + + @IsOptional() + @IsNumber() + capacityWeight?: number; + + @IsOptional() + @IsObject() + settings?: { + allowNegative?: boolean; + autoReorder?: boolean; + }; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; +} + +export class CreateWarehouseLocationDto { + @IsUUID() + warehouseId: string; + + @IsOptional() + @IsUUID() + parentId?: string; + + @IsString() + @MaxLength(30) + code: string; + + @IsString() + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + @MaxLength(50) + barcode?: string; + + @IsOptional() + @IsEnum(['zone', 'aisle', 'rack', 'shelf', 'bin']) + locationType?: 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin'; + + @IsOptional() + @IsString() + @MaxLength(10) + aisle?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + rack?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + shelf?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + bin?: string; + + @IsOptional() + @IsNumber() + capacityUnits?: number; + + @IsOptional() + @IsNumber() + capacityVolume?: number; + + @IsOptional() + @IsNumber() + capacityWeight?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedProductTypes?: string[]; + + @IsOptional() + @IsObject() + temperatureRange?: { min?: number; max?: number }; + + @IsOptional() + @IsObject() + humidityRange?: { min?: number; max?: number }; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isPickable?: boolean; + + @IsOptional() + @IsBoolean() + isReceivable?: boolean; +} + +export class UpdateWarehouseLocationDto { + @IsOptional() + @IsString() + @MaxLength(30) + code?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + barcode?: string; + + @IsOptional() + @IsEnum(['zone', 'aisle', 'rack', 'shelf', 'bin']) + locationType?: 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin'; + + @IsOptional() + @IsString() + @MaxLength(10) + aisle?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + rack?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + shelf?: string; + + @IsOptional() + @IsString() + @MaxLength(10) + bin?: string; + + @IsOptional() + @IsNumber() + capacityUnits?: number; + + @IsOptional() + @IsNumber() + capacityVolume?: number; + + @IsOptional() + @IsNumber() + capacityWeight?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedProductTypes?: string[]; + + @IsOptional() + @IsObject() + temperatureRange?: { min?: number; max?: number }; + + @IsOptional() + @IsObject() + humidityRange?: { min?: number; max?: number }; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isPickable?: boolean; + + @IsOptional() + @IsBoolean() + isReceivable?: boolean; +} diff --git a/src/modules/warehouses/dto/index.ts b/src/modules/warehouses/dto/index.ts new file mode 100644 index 0000000..f76b571 --- /dev/null +++ b/src/modules/warehouses/dto/index.ts @@ -0,0 +1,6 @@ +export { + CreateWarehouseDto, + UpdateWarehouseDto, + CreateWarehouseLocationDto, + UpdateWarehouseLocationDto, +} from './create-warehouse.dto'; diff --git a/src/modules/warehouses/entities/index.ts b/src/modules/warehouses/entities/index.ts new file mode 100644 index 0000000..fb6b6e3 --- /dev/null +++ b/src/modules/warehouses/entities/index.ts @@ -0,0 +1,3 @@ +export { Warehouse } from './warehouse.entity'; +export { WarehouseLocation } from './warehouse-location.entity'; +export { WarehouseZone } from './warehouse-zone.entity'; diff --git a/src/modules/warehouses/entities/warehouse-location.entity.ts b/src/modules/warehouses/entities/warehouse-location.entity.ts new file mode 100644 index 0000000..030ff0a --- /dev/null +++ b/src/modules/warehouses/entities/warehouse-location.entity.ts @@ -0,0 +1,111 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { Warehouse } from './warehouse.entity'; + +@Entity({ name: 'warehouse_locations', schema: 'inventory' }) +export class WarehouseLocation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @ManyToOne(() => Warehouse, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'warehouse_id' }) + warehouse: Warehouse; + + @Index() + @Column({ name: 'parent_id', type: 'uuid', nullable: true }) + parentId: string; + + @ManyToOne(() => WarehouseLocation, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'parent_id' }) + parent: WarehouseLocation; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 30 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Index() + @Column({ type: 'varchar', length: 50, nullable: true }) + barcode: string; + + // Tipo de ubicacion + @Index() + @Column({ name: 'location_type', type: 'varchar', length: 20, default: 'shelf' }) + locationType: 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin'; + + // Jerarquia + @Column({ name: 'hierarchy_path', type: 'text', nullable: true }) + hierarchyPath: string; + + @Column({ name: 'hierarchy_level', type: 'int', default: 0 }) + hierarchyLevel: number; + + // Coordenadas dentro del almacen + @Column({ type: 'varchar', length: 10, nullable: true }) + aisle: string; + + @Column({ type: 'varchar', length: 10, nullable: true }) + rack: string; + + @Column({ type: 'varchar', length: 10, nullable: true }) + shelf: string; + + @Column({ type: 'varchar', length: 10, nullable: true }) + bin: string; + + // Capacidad + @Column({ name: 'capacity_units', type: 'int', nullable: true }) + capacityUnits: number; + + @Column({ name: 'capacity_volume', type: 'decimal', precision: 10, scale: 4, nullable: true }) + capacityVolume: number; + + @Column({ name: 'capacity_weight', type: 'decimal', precision: 10, scale: 4, nullable: true }) + capacityWeight: number; + + // Restricciones + @Column({ name: 'allowed_product_types', type: 'text', array: true, default: '{}' }) + allowedProductTypes: string[]; + + @Column({ name: 'temperature_range', type: 'jsonb', nullable: true }) + temperatureRange: { min?: number; max?: number }; + + @Column({ name: 'humidity_range', type: 'jsonb', nullable: true }) + humidityRange: { min?: number; max?: number }; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_pickable', type: 'boolean', default: true }) + isPickable: boolean; + + @Column({ name: 'is_receivable', type: 'boolean', default: true }) + isReceivable: boolean; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/warehouses/entities/warehouse-zone.entity.ts b/src/modules/warehouses/entities/warehouse-zone.entity.ts new file mode 100644 index 0000000..d710cc5 --- /dev/null +++ b/src/modules/warehouses/entities/warehouse-zone.entity.ts @@ -0,0 +1,41 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm'; +import { Warehouse } from './warehouse.entity'; + +@Entity({ name: 'warehouse_zones', schema: 'inventory' }) +export class WarehouseZone { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'warehouse_id', type: 'uuid' }) + warehouseId: string; + + @ManyToOne(() => Warehouse, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'warehouse_id' }) + warehouse: Warehouse; + + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description?: string; + + @Column({ type: 'varchar', length: 20, nullable: true }) + color?: string; + + @Index() + @Column({ name: 'zone_type', type: 'varchar', length: 20, default: 'storage' }) + zoneType: 'storage' | 'picking' | 'packing' | 'shipping' | 'receiving' | 'quarantine'; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/warehouses/entities/warehouse.entity.ts b/src/modules/warehouses/entities/warehouse.entity.ts new file mode 100644 index 0000000..dc8c6f1 --- /dev/null +++ b/src/modules/warehouses/entities/warehouse.entity.ts @@ -0,0 +1,115 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +@Entity({ name: 'warehouses', schema: 'inventory' }) +export class Warehouse { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'branch_id', type: 'uuid', nullable: true }) + branchId: string; + + // Identificacion + @Index() + @Column({ type: 'varchar', length: 20 }) + code: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + // Tipo + @Index() + @Column({ name: 'warehouse_type', type: 'varchar', length: 20, default: 'standard' }) + warehouseType: 'standard' | 'transit' | 'returns' | 'quarantine' | 'virtual'; + + // Direccion + @Column({ name: 'address_line1', type: 'varchar', length: 200, nullable: true }) + addressLine1: string; + + @Column({ name: 'address_line2', type: 'varchar', length: 200, nullable: true }) + addressLine2: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + city: string; + + @Column({ type: 'varchar', length: 100, nullable: true }) + state: string; + + @Column({ name: 'postal_code', type: 'varchar', length: 20, nullable: true }) + postalCode: string; + + @Column({ type: 'varchar', length: 3, default: 'MEX' }) + country: string; + + // Contacto + @Column({ name: 'manager_name', type: 'varchar', length: 100, nullable: true }) + managerName: string; + + @Column({ type: 'varchar', length: 30, nullable: true }) + phone: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + email: string; + + // Geolocalizacion + @Column({ type: 'decimal', precision: 10, scale: 8, nullable: true }) + latitude: number; + + @Column({ type: 'decimal', precision: 11, scale: 8, nullable: true }) + longitude: number; + + // Capacidad + @Column({ name: 'capacity_units', type: 'int', nullable: true }) + capacityUnits: number; + + @Column({ name: 'capacity_volume', type: 'decimal', precision: 10, scale: 4, nullable: true }) + capacityVolume: number; + + @Column({ name: 'capacity_weight', type: 'decimal', precision: 10, scale: 4, nullable: true }) + capacityWeight: number; + + // Configuracion + @Column({ type: 'jsonb', default: {} }) + settings: { + allowNegative?: boolean; + autoReorder?: boolean; + }; + + // Estado + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_default', type: 'boolean', default: false }) + isDefault: boolean; + + // Metadata + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'updated_by', type: 'uuid', nullable: true }) + updatedBy: string; + + @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true }) + deletedAt: Date; +} diff --git a/src/modules/warehouses/index.ts b/src/modules/warehouses/index.ts new file mode 100644 index 0000000..2328bc3 --- /dev/null +++ b/src/modules/warehouses/index.ts @@ -0,0 +1,5 @@ +export { WarehousesModule, WarehousesModuleOptions } from './warehouses.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/warehouses/services/index.ts b/src/modules/warehouses/services/index.ts new file mode 100644 index 0000000..94a9de4 --- /dev/null +++ b/src/modules/warehouses/services/index.ts @@ -0,0 +1,5 @@ +export { + WarehousesService, + WarehouseSearchParams, + LocationSearchParams, +} from './warehouses.service'; diff --git a/src/modules/warehouses/services/warehouses.service.ts b/src/modules/warehouses/services/warehouses.service.ts new file mode 100644 index 0000000..8e8f8f4 --- /dev/null +++ b/src/modules/warehouses/services/warehouses.service.ts @@ -0,0 +1,294 @@ +import { Repository, FindOptionsWhere, ILike } from 'typeorm'; +import { Warehouse, WarehouseLocation } from '../entities'; +import { + CreateWarehouseDto, + UpdateWarehouseDto, + CreateWarehouseLocationDto, + UpdateWarehouseLocationDto, +} from '../dto'; + +export interface WarehouseSearchParams { + tenantId: string; + search?: string; + warehouseType?: 'standard' | 'transit' | 'returns' | 'quarantine' | 'virtual'; + branchId?: string; + isActive?: boolean; + limit?: number; + offset?: number; +} + +export interface LocationSearchParams { + warehouseId: string; + search?: string; + locationType?: 'zone' | 'aisle' | 'rack' | 'shelf' | 'bin'; + parentId?: string; + isActive?: boolean; + limit?: number; + offset?: number; +} + +export class WarehousesService { + constructor( + private readonly warehouseRepository: Repository, + private readonly locationRepository: Repository + ) {} + + // ==================== Warehouses ==================== + + async findAll(params: WarehouseSearchParams): Promise<{ data: Warehouse[]; total: number }> { + const { tenantId, search, warehouseType, branchId, isActive, limit = 50, offset = 0 } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { tenantId }; + + if (warehouseType) { + baseWhere.warehouseType = warehouseType; + } + + if (branchId) { + baseWhere.branchId = branchId; + } + + if (isActive !== undefined) { + baseWhere.isActive = isActive; + } + + if (search) { + where.push( + { ...baseWhere, name: ILike(`%${search}%`) }, + { ...baseWhere, code: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.warehouseRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { name: 'ASC' }, + }); + + return { data, total }; + } + + async findOne(id: string, tenantId: string): Promise { + return this.warehouseRepository.findOne({ where: { id, tenantId } }); + } + + async findByCode(code: string, tenantId: string): Promise { + return this.warehouseRepository.findOne({ where: { code, tenantId } }); + } + + async getDefaultWarehouse(tenantId: string): Promise { + return this.warehouseRepository.findOne({ where: { tenantId, isDefault: true, isActive: true } }); + } + + async create(tenantId: string, dto: CreateWarehouseDto, createdBy?: string): Promise { + // Check for existing code + const existingCode = await this.findByCode(dto.code, tenantId); + if (existingCode) { + throw new Error('A warehouse with this code already exists'); + } + + // If setting as default, unset other defaults + if (dto.isDefault) { + await this.warehouseRepository.update({ tenantId, isDefault: true }, { isDefault: false }); + } + + const warehouse = this.warehouseRepository.create({ + ...dto, + tenantId, + createdBy, + }); + + return this.warehouseRepository.save(warehouse); + } + + async update( + id: string, + tenantId: string, + dto: UpdateWarehouseDto, + updatedBy?: string + ): Promise { + const warehouse = await this.findOne(id, tenantId); + if (!warehouse) return null; + + // If changing code, check for duplicates + if (dto.code && dto.code !== warehouse.code) { + const existing = await this.findByCode(dto.code, tenantId); + if (existing) { + throw new Error('A warehouse with this code already exists'); + } + } + + // If setting as default, unset other defaults + if (dto.isDefault && !warehouse.isDefault) { + await this.warehouseRepository.update({ tenantId, isDefault: true }, { isDefault: false }); + } + + Object.assign(warehouse, { + ...dto, + updatedBy, + }); + + return this.warehouseRepository.save(warehouse); + } + + async delete(id: string, tenantId: string): Promise { + const warehouse = await this.findOne(id, tenantId); + if (!warehouse) return false; + + // Check if warehouse has locations + const locations = await this.locationRepository.findOne({ where: { warehouseId: id } }); + if (locations) { + throw new Error('Cannot delete warehouse with locations'); + } + + const result = await this.warehouseRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async getActiveWarehouses(tenantId: string): Promise { + return this.warehouseRepository.find({ + where: { tenantId, isActive: true }, + order: { isDefault: 'DESC', name: 'ASC' }, + }); + } + + // ==================== Locations ==================== + + async findAllLocations( + params: LocationSearchParams + ): Promise<{ data: WarehouseLocation[]; total: number }> { + const { warehouseId, search, locationType, parentId, isActive, limit = 100, offset = 0 } = params; + + const where: FindOptionsWhere[] = []; + const baseWhere: FindOptionsWhere = { warehouseId }; + + if (locationType) { + baseWhere.locationType = locationType; + } + + if (parentId !== undefined) { + baseWhere.parentId = parentId || undefined; + } + + if (isActive !== undefined) { + baseWhere.isActive = isActive; + } + + if (search) { + where.push( + { ...baseWhere, name: ILike(`%${search}%`) }, + { ...baseWhere, code: ILike(`%${search}%`) }, + { ...baseWhere, barcode: ILike(`%${search}%`) } + ); + } else { + where.push(baseWhere); + } + + const [data, total] = await this.locationRepository.findAndCount({ + where, + take: limit, + skip: offset, + order: { hierarchyPath: 'ASC', code: 'ASC' }, + }); + + return { data, total }; + } + + async findLocation(id: string): Promise { + return this.locationRepository.findOne({ where: { id }, relations: ['warehouse'] }); + } + + async findLocationByCode(code: string, warehouseId: string): Promise { + return this.locationRepository.findOne({ where: { code, warehouseId } }); + } + + async findLocationByBarcode(barcode: string): Promise { + return this.locationRepository.findOne({ where: { barcode }, relations: ['warehouse'] }); + } + + async createLocation(dto: CreateWarehouseLocationDto): Promise { + // Check for existing code in warehouse + const existingCode = await this.findLocationByCode(dto.code, dto.warehouseId); + if (existingCode) { + throw new Error('A location with this code already exists in this warehouse'); + } + + // Calculate hierarchy if parent exists + let hierarchyPath = `/${dto.code}`; + let hierarchyLevel = 0; + + if (dto.parentId) { + const parent = await this.findLocation(dto.parentId); + if (parent) { + hierarchyPath = `${parent.hierarchyPath}/${dto.code}`; + hierarchyLevel = parent.hierarchyLevel + 1; + } + } + + const location = this.locationRepository.create({ + ...dto, + hierarchyPath, + hierarchyLevel, + }); + + return this.locationRepository.save(location); + } + + async updateLocation( + id: string, + dto: UpdateWarehouseLocationDto + ): Promise { + const location = await this.findLocation(id); + if (!location) return null; + + // If changing code, check for duplicates + if (dto.code && dto.code !== location.code) { + const existing = await this.findLocationByCode(dto.code, location.warehouseId); + if (existing) { + throw new Error('A location with this code already exists in this warehouse'); + } + } + + Object.assign(location, dto); + return this.locationRepository.save(location); + } + + async deleteLocation(id: string): Promise { + const location = await this.findLocation(id); + if (!location) return false; + + // Check if location has children + const children = await this.locationRepository.findOne({ where: { parentId: id } }); + if (children) { + throw new Error('Cannot delete location with children'); + } + + const result = await this.locationRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async getLocationTree(warehouseId: string): Promise { + return this.locationRepository.find({ + where: { warehouseId, isActive: true }, + order: { hierarchyPath: 'ASC' }, + }); + } + + async getPickableLocations(warehouseId: string): Promise { + return this.locationRepository.find({ + where: { warehouseId, isActive: true, isPickable: true }, + order: { code: 'ASC' }, + }); + } + + async getReceivableLocations(warehouseId: string): Promise { + return this.locationRepository.find({ + where: { warehouseId, isActive: true, isReceivable: true }, + order: { code: 'ASC' }, + }); + } +} diff --git a/src/modules/warehouses/warehouses.module.ts b/src/modules/warehouses/warehouses.module.ts new file mode 100644 index 0000000..ad316ec --- /dev/null +++ b/src/modules/warehouses/warehouses.module.ts @@ -0,0 +1,41 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { WarehousesService } from './services'; +import { WarehousesController } from './controllers'; +import { Warehouse, WarehouseLocation } from './entities'; + +export interface WarehousesModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class WarehousesModule { + public router: Router; + public warehousesService: WarehousesService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: WarehousesModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const warehouseRepository = this.dataSource.getRepository(Warehouse); + const locationRepository = this.dataSource.getRepository(WarehouseLocation); + + this.warehousesService = new WarehousesService(warehouseRepository, locationRepository); + } + + private initializeRoutes(): void { + const warehousesController = new WarehousesController(this.warehousesService); + this.router.use(`${this.basePath}/warehouses`, warehousesController.router); + } + + static getEntities(): Function[] { + return [Warehouse, WarehouseLocation]; + } +} diff --git a/src/modules/webhooks/controllers/index.ts b/src/modules/webhooks/controllers/index.ts new file mode 100644 index 0000000..7423a12 --- /dev/null +++ b/src/modules/webhooks/controllers/index.ts @@ -0,0 +1 @@ +export { WebhooksController } from './webhooks.controller'; diff --git a/src/modules/webhooks/controllers/webhooks.controller.ts b/src/modules/webhooks/controllers/webhooks.controller.ts new file mode 100644 index 0000000..8887b2d --- /dev/null +++ b/src/modules/webhooks/controllers/webhooks.controller.ts @@ -0,0 +1,276 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { WebhooksService } from '../services/webhooks.service'; + +export class WebhooksController { + public router: Router; + + constructor(private readonly webhooksService: WebhooksService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Event Types + this.router.get('/event-types', this.findAllEventTypes.bind(this)); + this.router.get('/event-types/:code', this.findEventTypeByCode.bind(this)); + this.router.get('/event-types/category/:category', this.findEventTypesByCategory.bind(this)); + + // Endpoints + this.router.get('/endpoints', this.findAllEndpoints.bind(this)); + this.router.get('/endpoints/active', this.findActiveEndpoints.bind(this)); + this.router.get('/endpoints/:id', this.findEndpoint.bind(this)); + this.router.post('/endpoints', this.createEndpoint.bind(this)); + this.router.patch('/endpoints/:id', this.updateEndpoint.bind(this)); + this.router.delete('/endpoints/:id', this.deleteEndpoint.bind(this)); + this.router.patch('/endpoints/:id/toggle', this.toggleEndpoint.bind(this)); + this.router.get('/endpoints/:id/stats', this.getEndpointStats.bind(this)); + this.router.get('/endpoints/:id/deliveries', this.findDeliveriesForEndpoint.bind(this)); + + // Events + this.router.post('/events', this.createEvent.bind(this)); + this.router.get('/events/:id', this.findEvent.bind(this)); + this.router.get('/events/pending', this.findPendingEvents.bind(this)); + + // Deliveries + this.router.get('/deliveries/:id', this.findDelivery.bind(this)); + this.router.get('/events/:eventId/deliveries', this.findDeliveriesForEvent.bind(this)); + } + + // ============================================ + // EVENT TYPES + // ============================================ + + private async findAllEventTypes(req: Request, res: Response, next: NextFunction): Promise { + try { + const eventTypes = await this.webhooksService.findAllEventTypes(); + res.json({ data: eventTypes, total: eventTypes.length }); + } catch (error) { + next(error); + } + } + + private async findEventTypeByCode(req: Request, res: Response, next: NextFunction): Promise { + try { + const { code } = req.params; + const eventType = await this.webhooksService.findEventTypeByCode(code); + + if (!eventType) { + res.status(404).json({ error: 'Event type not found' }); + return; + } + + res.json({ data: eventType }); + } catch (error) { + next(error); + } + } + + private async findEventTypesByCategory(req: Request, res: Response, next: NextFunction): Promise { + try { + const { category } = req.params; + const eventTypes = await this.webhooksService.findEventTypesByCategory(category); + res.json({ data: eventTypes, total: eventTypes.length }); + } catch (error) { + next(error); + } + } + + // ============================================ + // ENDPOINTS + // ============================================ + + private async findAllEndpoints(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const endpoints = await this.webhooksService.findAllEndpoints(tenantId); + res.json({ data: endpoints, total: endpoints.length }); + } catch (error) { + next(error); + } + } + + private async findActiveEndpoints(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const endpoints = await this.webhooksService.findActiveEndpoints(tenantId); + res.json({ data: endpoints, total: endpoints.length }); + } catch (error) { + next(error); + } + } + + private async findEndpoint(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const endpoint = await this.webhooksService.findEndpoint(id); + + if (!endpoint) { + res.status(404).json({ error: 'Endpoint not found' }); + return; + } + + res.json({ data: endpoint }); + } catch (error) { + next(error); + } + } + + private async createEndpoint(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const endpoint = await this.webhooksService.createEndpoint(tenantId, req.body, userId); + res.status(201).json({ data: endpoint }); + } catch (error) { + next(error); + } + } + + private async updateEndpoint(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const userId = req.headers['x-user-id'] as string; + + const endpoint = await this.webhooksService.updateEndpoint(id, req.body, userId); + + if (!endpoint) { + res.status(404).json({ error: 'Endpoint not found' }); + return; + } + + res.json({ data: endpoint }); + } catch (error) { + next(error); + } + } + + private async deleteEndpoint(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deleted = await this.webhooksService.deleteEndpoint(id); + + if (!deleted) { + res.status(404).json({ error: 'Endpoint not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } + + private async toggleEndpoint(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { isActive } = req.body; + + const endpoint = await this.webhooksService.toggleEndpoint(id, isActive); + + if (!endpoint) { + res.status(404).json({ error: 'Endpoint not found' }); + return; + } + + res.json({ data: endpoint }); + } catch (error) { + next(error); + } + } + + private async getEndpointStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const startDate = new Date(req.query.startDate as string || Date.now() - 7 * 24 * 60 * 60 * 1000); + const endDate = new Date(req.query.endDate as string || Date.now()); + + const stats = await this.webhooksService.getEndpointStats(id, startDate, endDate); + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + private async findDeliveriesForEndpoint(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const limit = parseInt(req.query.limit as string) || 50; + + const deliveries = await this.webhooksService.findDeliveriesForEndpoint(id, limit); + res.json({ data: deliveries, total: deliveries.length }); + } catch (error) { + next(error); + } + } + + // ============================================ + // EVENTS + // ============================================ + + private async createEvent(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const event = await this.webhooksService.createEvent(tenantId, req.body); + res.status(201).json({ data: event }); + } catch (error) { + next(error); + } + } + + private async findEvent(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const event = await this.webhooksService.findEvent(id); + + if (!event) { + res.status(404).json({ error: 'Event not found' }); + return; + } + + res.json({ data: event }); + } catch (error) { + next(error); + } + } + + private async findPendingEvents(req: Request, res: Response, next: NextFunction): Promise { + try { + const limit = parseInt(req.query.limit as string) || 100; + const events = await this.webhooksService.findPendingEvents(limit); + res.json({ data: events, total: events.length }); + } catch (error) { + next(error); + } + } + + // ============================================ + // DELIVERIES + // ============================================ + + private async findDelivery(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const delivery = await this.webhooksService.findDelivery(id); + + if (!delivery) { + res.status(404).json({ error: 'Delivery not found' }); + return; + } + + res.json({ data: delivery }); + } catch (error) { + next(error); + } + } + + private async findDeliveriesForEvent(req: Request, res: Response, next: NextFunction): Promise { + try { + const { eventId } = req.params; + const deliveries = await this.webhooksService.findDeliveriesForEvent(eventId); + res.json({ data: deliveries, total: deliveries.length }); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/webhooks/dto/index.ts b/src/modules/webhooks/dto/index.ts new file mode 100644 index 0000000..e89b97d --- /dev/null +++ b/src/modules/webhooks/dto/index.ts @@ -0,0 +1,8 @@ +export { + CreateWebhookEndpointDto, + UpdateWebhookEndpointDto, + ToggleWebhookEndpointDto, + CreateWebhookEventDto, + UpdateDeliveryResultDto, + ScheduleRetryDto, +} from './webhook.dto'; diff --git a/src/modules/webhooks/dto/webhook.dto.ts b/src/modules/webhooks/dto/webhook.dto.ts new file mode 100644 index 0000000..92f6763 --- /dev/null +++ b/src/modules/webhooks/dto/webhook.dto.ts @@ -0,0 +1,178 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsObject, + IsUUID, + IsUrl, + MaxLength, + MinLength, + Min, + Max, +} from 'class-validator'; + +// ============================================ +// ENDPOINT DTOs +// ============================================ + +export class CreateWebhookEndpointDto { + @IsString() + @MinLength(2) + @MaxLength(100) + name: string; + + @IsOptional() + @IsString() + description?: string; + + @IsUrl() + @MaxLength(500) + url: string; + + @IsOptional() + @IsString() + @MaxLength(20) + authType?: string; + + @IsOptional() + @IsObject() + authConfig?: Record; + + @IsArray() + @IsString({ each: true }) + subscribedEvents: string[]; + + @IsOptional() + @IsObject() + headers?: Record; + + @IsOptional() + @IsNumber() + @Min(1000) + @Max(30000) + timeout?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(10) + retryCount?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateWebhookEndpointDto { + @IsOptional() + @IsString() + @MaxLength(100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsUrl() + @MaxLength(500) + url?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + authType?: string; + + @IsOptional() + @IsObject() + authConfig?: Record; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + subscribedEvents?: string[]; + + @IsOptional() + @IsObject() + headers?: Record; + + @IsOptional() + @IsNumber() + @Min(1000) + @Max(30000) + timeout?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(10) + retryCount?: number; +} + +export class ToggleWebhookEndpointDto { + @IsBoolean() + isActive: boolean; +} + +// ============================================ +// EVENT DTOs +// ============================================ + +export class CreateWebhookEventDto { + @IsString() + @MaxLength(100) + eventType: string; + + @IsOptional() + @IsString() + @MaxLength(100) + entityType?: string; + + @IsOptional() + @IsUUID() + entityId?: string; + + @IsObject() + payload: Record; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +// ============================================ +// DELIVERY DTOs +// ============================================ + +export class UpdateDeliveryResultDto { + @IsString() + @MaxLength(20) + status: string; + + @IsOptional() + @IsNumber() + responseStatus?: number; + + @IsOptional() + @IsString() + responseBody?: string; + + @IsOptional() + @IsNumber() + durationMs?: number; + + @IsOptional() + @IsString() + errorMessage?: string; +} + +export class ScheduleRetryDto { + @IsString() + nextRetryAt: string; + + @IsNumber() + @Min(1) + attemptNumber: number; +} diff --git a/src/modules/webhooks/entities/delivery.entity.ts b/src/modules/webhooks/entities/delivery.entity.ts new file mode 100644 index 0000000..3cb91d8 --- /dev/null +++ b/src/modules/webhooks/entities/delivery.entity.ts @@ -0,0 +1,97 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WebhookEndpoint } from './endpoint.entity'; + +export type DeliveryStatus = 'pending' | 'sending' | 'delivered' | 'failed' | 'retrying' | 'cancelled'; + +@Entity({ name: 'deliveries', schema: 'webhooks' }) +export class WebhookDelivery { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'endpoint_id', type: 'uuid' }) + endpointId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'event_type', type: 'varchar', length: 100 }) + eventType: string; + + @Column({ name: 'event_id', type: 'uuid' }) + eventId: string; + + @Column({ name: 'payload', type: 'jsonb' }) + payload: Record; + + @Column({ name: 'payload_hash', type: 'varchar', length: 64, nullable: true }) + payloadHash: string; + + @Column({ name: 'request_url', type: 'text' }) + requestUrl: string; + + @Column({ name: 'request_method', type: 'varchar', length: 10 }) + requestMethod: string; + + @Column({ name: 'request_headers', type: 'jsonb', default: {} }) + requestHeaders: Record; + + @Column({ name: 'response_status', type: 'int', nullable: true }) + responseStatus: number; + + @Column({ name: 'response_headers', type: 'jsonb', default: {} }) + responseHeaders: Record; + + @Column({ name: 'response_body', type: 'text', nullable: true }) + responseBody: string; + + @Column({ name: 'response_time_ms', type: 'int', nullable: true }) + responseTimeMs: number; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: DeliveryStatus; + + @Column({ name: 'attempt_number', type: 'int', default: 1 }) + attemptNumber: number; + + @Column({ name: 'max_attempts', type: 'int', default: 5 }) + maxAttempts: number; + + @Index() + @Column({ name: 'next_retry_at', type: 'timestamptz', nullable: true }) + nextRetryAt: Date; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'error_code', type: 'varchar', length: 50, nullable: true }) + errorCode: string; + + @Column({ name: 'scheduled_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) + scheduledAt: Date; + + @Column({ name: 'started_at', type: 'timestamptz', nullable: true }) + startedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => WebhookEndpoint, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'endpoint_id' }) + endpoint: WebhookEndpoint; +} diff --git a/src/modules/webhooks/entities/endpoint-log.entity.ts b/src/modules/webhooks/entities/endpoint-log.entity.ts new file mode 100644 index 0000000..513e4a7 --- /dev/null +++ b/src/modules/webhooks/entities/endpoint-log.entity.ts @@ -0,0 +1,46 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WebhookEndpoint } from './endpoint.entity'; + +export type WebhookLogType = 'config_changed' | 'activated' | 'deactivated' | 'verified' | 'error' | 'rate_limited' | 'created'; + +@Entity({ name: 'endpoint_logs', schema: 'webhooks' }) +export class WebhookEndpointLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'endpoint_id', type: 'uuid' }) + endpointId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'log_type', type: 'varchar', length: 30 }) + logType: WebhookLogType; + + @Column({ name: 'message', type: 'text', nullable: true }) + message: string; + + @Column({ name: 'details', type: 'jsonb', default: {} }) + details: Record; + + @Column({ name: 'actor_id', type: 'uuid', nullable: true }) + actorId: string; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => WebhookEndpoint, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'endpoint_id' }) + endpoint: WebhookEndpoint; +} diff --git a/src/modules/webhooks/entities/endpoint.entity.ts b/src/modules/webhooks/entities/endpoint.entity.ts new file mode 100644 index 0000000..7f12106 --- /dev/null +++ b/src/modules/webhooks/entities/endpoint.entity.ts @@ -0,0 +1,110 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type AuthType = 'none' | 'basic' | 'bearer' | 'hmac' | 'oauth2'; + +@Entity({ name: 'endpoints', schema: 'webhooks' }) +@Unique(['tenantId', 'url']) +export class WebhookEndpoint { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'url', type: 'text' }) + url: string; + + @Column({ name: 'http_method', type: 'varchar', length: 10, default: 'POST' }) + httpMethod: string; + + @Column({ name: 'auth_type', type: 'varchar', length: 30, default: 'none' }) + authType: AuthType; + + @Column({ name: 'auth_config', type: 'jsonb', default: {} }) + authConfig: Record; + + @Column({ name: 'custom_headers', type: 'jsonb', default: {} }) + customHeaders: Record; + + @Column({ name: 'subscribed_events', type: 'text', array: true, default: [] }) + subscribedEvents: string[]; + + @Column({ name: 'filters', type: 'jsonb', default: {} }) + filters: Record; + + @Column({ name: 'retry_enabled', type: 'boolean', default: true }) + retryEnabled: boolean; + + @Column({ name: 'max_retries', type: 'int', default: 5 }) + maxRetries: number; + + @Column({ name: 'retry_delay_seconds', type: 'int', default: 60 }) + retryDelaySeconds: number; + + @Column({ name: 'retry_backoff_multiplier', type: 'decimal', precision: 3, scale: 1, default: 2.0 }) + retryBackoffMultiplier: number; + + @Column({ name: 'timeout_seconds', type: 'int', default: 30 }) + timeoutSeconds: number; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_verified', type: 'boolean', default: false }) + isVerified: boolean; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'signing_secret', type: 'varchar', length: 255, nullable: true }) + signingSecret: string; + + @Column({ name: 'total_deliveries', type: 'int', default: 0 }) + totalDeliveries: number; + + @Column({ name: 'successful_deliveries', type: 'int', default: 0 }) + successfulDeliveries: number; + + @Column({ name: 'failed_deliveries', type: 'int', default: 0 }) + failedDeliveries: number; + + @Column({ name: 'last_delivery_at', type: 'timestamptz', nullable: true }) + lastDeliveryAt: Date; + + @Column({ name: 'last_success_at', type: 'timestamptz', nullable: true }) + lastSuccessAt: Date; + + @Column({ name: 'last_failure_at', type: 'timestamptz', nullable: true }) + lastFailureAt: Date; + + @Column({ name: 'rate_limit_per_minute', type: 'int', default: 60 }) + rateLimitPerMinute: number; + + @Column({ name: 'rate_limit_per_hour', type: 'int', default: 1000 }) + rateLimitPerHour: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; +} diff --git a/src/modules/webhooks/entities/event-type.entity.ts b/src/modules/webhooks/entities/event-type.entity.ts new file mode 100644 index 0000000..9a4ec81 --- /dev/null +++ b/src/modules/webhooks/entities/event-type.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +export type EventCategory = 'sales' | 'inventory' | 'customers' | 'auth' | 'billing' | 'system'; + +@Entity({ name: 'event_types', schema: 'webhooks' }) +export class WebhookEventType { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index({ unique: true }) + @Column({ name: 'code', type: 'varchar', length: 100 }) + code: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: EventCategory; + + @Column({ name: 'payload_schema', type: 'jsonb', default: {} }) + payloadSchema: Record; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'is_internal', type: 'boolean', default: false }) + isInternal: boolean; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/modules/webhooks/entities/event.entity.ts b/src/modules/webhooks/entities/event.entity.ts new file mode 100644 index 0000000..d93932b --- /dev/null +++ b/src/modules/webhooks/entities/event.entity.ts @@ -0,0 +1,61 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type WebhookEventStatus = 'pending' | 'processing' | 'dispatched' | 'failed'; + +@Entity({ name: 'events', schema: 'webhooks' }) +export class WebhookEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'event_type', type: 'varchar', length: 100 }) + eventType: string; + + @Column({ name: 'payload', type: 'jsonb' }) + payload: Record; + + @Column({ name: 'resource_type', type: 'varchar', length: 100, nullable: true }) + resourceType: string; + + @Column({ name: 'resource_id', type: 'uuid', nullable: true }) + resourceId: string; + + @Column({ name: 'triggered_by', type: 'uuid', nullable: true }) + triggeredBy: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: WebhookEventStatus; + + @Column({ name: 'processed_at', type: 'timestamptz', nullable: true }) + processedAt: Date; + + @Column({ name: 'dispatched_endpoints', type: 'int', default: 0 }) + dispatchedEndpoints: number; + + @Column({ name: 'failed_endpoints', type: 'int', default: 0 }) + failedEndpoints: number; + + @Index() + @Column({ name: 'idempotency_key', type: 'varchar', length: 255, nullable: true }) + idempotencyKey: string; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt: Date; +} diff --git a/src/modules/webhooks/entities/index.ts b/src/modules/webhooks/entities/index.ts new file mode 100644 index 0000000..3c8a0c2 --- /dev/null +++ b/src/modules/webhooks/entities/index.ts @@ -0,0 +1,6 @@ +export { WebhookEventType, EventCategory } from './event-type.entity'; +export { WebhookEndpoint, AuthType } from './endpoint.entity'; +export { WebhookDelivery, DeliveryStatus } from './delivery.entity'; +export { WebhookEvent, WebhookEventStatus } from './event.entity'; +export { WebhookSubscription } from './subscription.entity'; +export { WebhookEndpointLog, WebhookLogType } from './endpoint-log.entity'; diff --git a/src/modules/webhooks/entities/subscription.entity.ts b/src/modules/webhooks/entities/subscription.entity.ts new file mode 100644 index 0000000..a30b746 --- /dev/null +++ b/src/modules/webhooks/entities/subscription.entity.ts @@ -0,0 +1,55 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WebhookEndpoint } from './endpoint.entity'; +import { WebhookEventType } from './event-type.entity'; + +@Entity({ name: 'subscriptions', schema: 'webhooks' }) +@Unique(['endpointId', 'eventTypeId']) +export class WebhookSubscription { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'endpoint_id', type: 'uuid' }) + endpointId: string; + + @Index() + @Column({ name: 'event_type_id', type: 'uuid' }) + eventTypeId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'filters', type: 'jsonb', default: {} }) + filters: Record; + + @Column({ name: 'payload_template', type: 'jsonb', nullable: true }) + payloadTemplate: Record; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => WebhookEndpoint, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'endpoint_id' }) + endpoint: WebhookEndpoint; + + @ManyToOne(() => WebhookEventType, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'event_type_id' }) + eventType: WebhookEventType; +} diff --git a/src/modules/webhooks/index.ts b/src/modules/webhooks/index.ts new file mode 100644 index 0000000..8250561 --- /dev/null +++ b/src/modules/webhooks/index.ts @@ -0,0 +1,5 @@ +export { WebhooksModule, WebhooksModuleOptions } from './webhooks.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/webhooks/services/index.ts b/src/modules/webhooks/services/index.ts new file mode 100644 index 0000000..ad6802e --- /dev/null +++ b/src/modules/webhooks/services/index.ts @@ -0,0 +1 @@ +export { WebhooksService } from './webhooks.service'; diff --git a/src/modules/webhooks/services/webhooks.service.ts b/src/modules/webhooks/services/webhooks.service.ts new file mode 100644 index 0000000..da10e72 --- /dev/null +++ b/src/modules/webhooks/services/webhooks.service.ts @@ -0,0 +1,263 @@ +import { Repository, FindOptionsWhere, In, LessThan } from 'typeorm'; +import { WebhookEventType, WebhookEndpoint, WebhookDelivery, WebhookEvent } from '../entities'; + +export class WebhooksService { + constructor( + private readonly eventTypeRepository: Repository, + private readonly endpointRepository: Repository, + private readonly deliveryRepository: Repository, + private readonly eventRepository: Repository + ) {} + + // ============================================ + // EVENT TYPES + // ============================================ + + async findAllEventTypes(): Promise { + return this.eventTypeRepository.find({ + where: { isActive: true }, + order: { category: 'ASC', code: 'ASC' }, + }); + } + + async findEventTypeByCode(code: string): Promise { + return this.eventTypeRepository.findOne({ where: { code } }); + } + + async findEventTypesByCategory(category: string): Promise { + return this.eventTypeRepository.find({ + where: { category: category as any, isActive: true }, + order: { code: 'ASC' }, + }); + } + + // ============================================ + // ENDPOINTS + // ============================================ + + async findAllEndpoints(tenantId: string): Promise { + return this.endpointRepository.find({ + where: { tenantId }, + order: { name: 'ASC' }, + }); + } + + async findActiveEndpoints(tenantId: string): Promise { + return this.endpointRepository.find({ + where: { tenantId, isActive: true }, + order: { name: 'ASC' }, + }); + } + + async findEndpoint(id: string): Promise { + return this.endpointRepository.findOne({ where: { id } }); + } + + async findEndpointByUrl(tenantId: string, url: string): Promise { + return this.endpointRepository.findOne({ where: { tenantId, url } }); + } + + async createEndpoint( + tenantId: string, + data: Partial, + createdBy?: string + ): Promise { + const endpoint = this.endpointRepository.create({ + ...data, + tenantId, + createdBy, + }); + return this.endpointRepository.save(endpoint); + } + + async updateEndpoint( + id: string, + data: Partial, + updatedBy?: string + ): Promise { + const endpoint = await this.findEndpoint(id); + if (!endpoint) return null; + + Object.assign(endpoint, data, { updatedBy }); + return this.endpointRepository.save(endpoint); + } + + async deleteEndpoint(id: string): Promise { + const result = await this.endpointRepository.softDelete(id); + return (result.affected ?? 0) > 0; + } + + async toggleEndpoint(id: string, isActive: boolean): Promise { + const endpoint = await this.findEndpoint(id); + if (!endpoint) return null; + + endpoint.isActive = isActive; + return this.endpointRepository.save(endpoint); + } + + async findEndpointsForEvent(tenantId: string, eventTypeCode: string): Promise { + return this.endpointRepository + .createQueryBuilder('endpoint') + .where('endpoint.tenant_id = :tenantId', { tenantId }) + .andWhere('endpoint.is_active = true') + .andWhere(':eventTypeCode = ANY(endpoint.subscribed_events)', { eventTypeCode }) + .getMany(); + } + + async updateEndpointHealth( + id: string, + isHealthy: boolean, + consecutiveFailures: number + ): Promise { + await this.endpointRepository.update(id, { + isHealthy, + consecutiveFailures, + lastHealthCheck: new Date(), + }); + } + + // ============================================ + // EVENTS + // ============================================ + + async createEvent(tenantId: string, data: Partial): Promise { + const event = this.eventRepository.create({ + ...data, + tenantId, + status: 'pending', + }); + return this.eventRepository.save(event); + } + + async findEvent(id: string): Promise { + return this.eventRepository.findOne({ + where: { id }, + relations: ['deliveries'], + }); + } + + async findPendingEvents(limit: number = 100): Promise { + return this.eventRepository.find({ + where: { status: 'pending' }, + order: { createdAt: 'ASC' }, + take: limit, + }); + } + + async updateEventStatus(id: string, status: string): Promise { + const updates: Partial = { status: status as any }; + if (status === 'processed') { + updates.processedAt = new Date(); + } + await this.eventRepository.update(id, updates); + } + + // ============================================ + // DELIVERIES + // ============================================ + + async createDelivery(data: Partial): Promise { + const delivery = this.deliveryRepository.create({ + ...data, + status: 'pending', + }); + return this.deliveryRepository.save(delivery); + } + + async findDelivery(id: string): Promise { + return this.deliveryRepository.findOne({ where: { id } }); + } + + async findDeliveriesForEvent(eventId: string): Promise { + return this.deliveryRepository.find({ + where: { eventId }, + order: { attemptNumber: 'ASC' }, + }); + } + + async findDeliveriesForEndpoint( + endpointId: string, + limit: number = 50 + ): Promise { + return this.deliveryRepository.find({ + where: { endpointId }, + order: { createdAt: 'DESC' }, + take: limit, + relations: ['event'], + }); + } + + async updateDeliveryResult( + id: string, + status: string, + responseStatus?: number, + responseBody?: string, + duration?: number, + errorMessage?: string + ): Promise { + const updates: Partial = { + status: status as any, + responseStatus, + responseBody, + durationMs: duration, + errorMessage, + completedAt: new Date(), + }; + + await this.deliveryRepository.update(id, updates); + } + + async findPendingRetries(limit: number = 100): Promise { + const now = new Date(); + return this.deliveryRepository.find({ + where: { + status: 'pending', + nextRetryAt: LessThan(now), + }, + order: { nextRetryAt: 'ASC' }, + take: limit, + }); + } + + async scheduleRetry(id: string, nextRetryAt: Date, attemptNumber: number): Promise { + await this.deliveryRepository.update(id, { + nextRetryAt, + attemptNumber, + status: 'pending', + }); + } + + // ============================================ + // STATISTICS + // ============================================ + + async getEndpointStats( + endpointId: string, + startDate: Date, + endDate: Date + ): Promise<{ + total: number; + success: number; + failed: number; + avgDuration: number; + }> { + const result = await this.deliveryRepository + .createQueryBuilder('d') + .select('COUNT(*)', 'total') + .addSelect('SUM(CASE WHEN d.status = :success THEN 1 ELSE 0 END)', 'success') + .addSelect('SUM(CASE WHEN d.status = :failed THEN 1 ELSE 0 END)', 'failed') + .addSelect('AVG(d.duration_ms)', 'avgDuration') + .where('d.endpoint_id = :endpointId', { endpointId }) + .andWhere('d.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .setParameter('success', 'success') + .setParameter('failed', 'failed') + .getRawOne(); + + return { + total: parseInt(result.total) || 0, + success: parseInt(result.success) || 0, + failed: parseInt(result.failed) || 0, + avgDuration: parseFloat(result.avgDuration) || 0, + }; + } +} diff --git a/src/modules/webhooks/webhooks.module.ts b/src/modules/webhooks/webhooks.module.ts new file mode 100644 index 0000000..0f8dfea --- /dev/null +++ b/src/modules/webhooks/webhooks.module.ts @@ -0,0 +1,58 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { WebhooksService } from './services'; +import { WebhooksController } from './controllers'; +import { + WebhookEventType, + WebhookEndpoint, + WebhookDelivery, + WebhookEvent, +} from './entities'; + +export interface WebhooksModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class WebhooksModule { + public router: Router; + public webhooksService: WebhooksService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: WebhooksModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const eventTypeRepository = this.dataSource.getRepository(WebhookEventType); + const endpointRepository = this.dataSource.getRepository(WebhookEndpoint); + const deliveryRepository = this.dataSource.getRepository(WebhookDelivery); + const eventRepository = this.dataSource.getRepository(WebhookEvent); + + this.webhooksService = new WebhooksService( + eventTypeRepository, + endpointRepository, + deliveryRepository, + eventRepository + ); + } + + private initializeRoutes(): void { + const webhooksController = new WebhooksController(this.webhooksService); + this.router.use(`${this.basePath}/webhooks`, webhooksController.router); + } + + static getEntities(): Function[] { + return [ + WebhookEventType, + WebhookEndpoint, + WebhookDelivery, + WebhookEvent, + ]; + } +} diff --git a/src/modules/whatsapp/controllers/index.ts b/src/modules/whatsapp/controllers/index.ts new file mode 100644 index 0000000..60d21b3 --- /dev/null +++ b/src/modules/whatsapp/controllers/index.ts @@ -0,0 +1 @@ +export { WhatsAppController } from './whatsapp.controller'; diff --git a/src/modules/whatsapp/controllers/whatsapp.controller.ts b/src/modules/whatsapp/controllers/whatsapp.controller.ts new file mode 100644 index 0000000..ab38207 --- /dev/null +++ b/src/modules/whatsapp/controllers/whatsapp.controller.ts @@ -0,0 +1,500 @@ +import { Request, Response, NextFunction, Router } from 'express'; +import { WhatsAppService, MessageFilters, ContactFilters } from '../services/whatsapp.service'; + +export class WhatsAppController { + public router: Router; + + constructor(private readonly whatsappService: WhatsAppService) { + this.router = Router(); + this.initializeRoutes(); + } + + private initializeRoutes(): void { + // Accounts + this.router.get('/accounts', this.findAllAccounts.bind(this)); + this.router.get('/accounts/active', this.findActiveAccounts.bind(this)); + this.router.get('/accounts/:id', this.findAccount.bind(this)); + this.router.post('/accounts', this.createAccount.bind(this)); + this.router.patch('/accounts/:id', this.updateAccount.bind(this)); + this.router.patch('/accounts/:id/status', this.updateAccountStatus.bind(this)); + this.router.get('/accounts/:id/stats', this.getAccountStats.bind(this)); + + // Contacts + this.router.get('/accounts/:accountId/contacts', this.findContacts.bind(this)); + this.router.get('/contacts/:id', this.findContact.bind(this)); + this.router.get('/accounts/:accountId/contacts/phone/:phoneNumber', this.findContactByPhone.bind(this)); + this.router.post('/contacts', this.createContact.bind(this)); + this.router.patch('/contacts/:id', this.updateContact.bind(this)); + this.router.post('/contacts/:id/opt-in', this.optInContact.bind(this)); + this.router.post('/contacts/:id/opt-out', this.optOutContact.bind(this)); + this.router.post('/contacts/:id/tags', this.addTagToContact.bind(this)); + this.router.delete('/contacts/:id/tags/:tag', this.removeTagFromContact.bind(this)); + + // Messages + this.router.get('/accounts/:accountId/messages', this.findMessages.bind(this)); + this.router.get('/messages/:id', this.findMessage.bind(this)); + this.router.get('/contacts/:contactId/messages', this.findConversationMessages.bind(this)); + this.router.post('/messages', this.createMessage.bind(this)); + this.router.patch('/messages/:id/status', this.updateMessageStatus.bind(this)); + + // Templates + this.router.get('/accounts/:accountId/templates', this.findTemplates.bind(this)); + this.router.get('/accounts/:accountId/templates/approved', this.findApprovedTemplates.bind(this)); + this.router.get('/templates/:id', this.findTemplate.bind(this)); + this.router.post('/templates', this.createTemplate.bind(this)); + this.router.patch('/templates/:id', this.updateTemplate.bind(this)); + this.router.patch('/templates/:id/status', this.updateTemplateStatus.bind(this)); + this.router.delete('/templates/:id', this.deactivateTemplate.bind(this)); + } + + // ============================================ + // ACCOUNTS + // ============================================ + + private async findAllAccounts(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const accounts = await this.whatsappService.findAllAccounts(tenantId); + res.json({ data: accounts, total: accounts.length }); + } catch (error) { + next(error); + } + } + + private async findActiveAccounts(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const accounts = await this.whatsappService.findActiveAccounts(tenantId); + res.json({ data: accounts, total: accounts.length }); + } catch (error) { + next(error); + } + } + + private async findAccount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const account = await this.whatsappService.findAccount(id); + + if (!account) { + res.status(404).json({ error: 'Account not found' }); + return; + } + + res.json({ data: account }); + } catch (error) { + next(error); + } + } + + private async createAccount(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const userId = req.headers['x-user-id'] as string; + + const account = await this.whatsappService.createAccount(tenantId, req.body, userId); + res.status(201).json({ data: account }); + } catch (error) { + next(error); + } + } + + private async updateAccount(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const account = await this.whatsappService.updateAccount(id, req.body); + + if (!account) { + res.status(404).json({ error: 'Account not found' }); + return; + } + + res.json({ data: account }); + } catch (error) { + next(error); + } + } + + private async updateAccountStatus(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { status } = req.body; + + const updated = await this.whatsappService.updateAccountStatus(id, status); + + if (!updated) { + res.status(404).json({ error: 'Account not found' }); + return; + } + + res.json({ data: { success: true } }); + } catch (error) { + next(error); + } + } + + private async getAccountStats(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const startDate = new Date(req.query.startDate as string || Date.now() - 30 * 24 * 60 * 60 * 1000); + const endDate = new Date(req.query.endDate as string || Date.now()); + + const stats = await this.whatsappService.getAccountStats(id, startDate, endDate); + res.json({ data: stats }); + } catch (error) { + next(error); + } + } + + // ============================================ + // CONTACTS + // ============================================ + + private async findContacts(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { accountId } = req.params; + const filters: ContactFilters = { + conversationStatus: req.query.conversationStatus as string, + tag: req.query.tag as string, + }; + + if (req.query.optedIn !== undefined) { + filters.optedIn = req.query.optedIn === 'true'; + } + + const limit = parseInt(req.query.limit as string) || 50; + + const contacts = await this.whatsappService.findContacts(tenantId, accountId, filters, limit); + res.json({ data: contacts, total: contacts.length }); + } catch (error) { + next(error); + } + } + + private async findContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const contact = await this.whatsappService.findContact(id); + + if (!contact) { + res.status(404).json({ error: 'Contact not found' }); + return; + } + + res.json({ data: contact }); + } catch (error) { + next(error); + } + } + + private async findContactByPhone(req: Request, res: Response, next: NextFunction): Promise { + try { + const { accountId, phoneNumber } = req.params; + const contact = await this.whatsappService.findContactByPhone(accountId, phoneNumber); + + if (!contact) { + res.status(404).json({ error: 'Contact not found' }); + return; + } + + res.json({ data: contact }); + } catch (error) { + next(error); + } + } + + private async createContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { accountId, ...data } = req.body; + + const contact = await this.whatsappService.createContact(tenantId, accountId, data); + res.status(201).json({ data: contact }); + } catch (error) { + next(error); + } + } + + private async updateContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const contact = await this.whatsappService.updateContact(id, req.body); + + if (!contact) { + res.status(404).json({ error: 'Contact not found' }); + return; + } + + res.json({ data: contact }); + } catch (error) { + next(error); + } + } + + private async optInContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const optedIn = await this.whatsappService.optInContact(id); + + if (!optedIn) { + res.status(404).json({ error: 'Contact not found' }); + return; + } + + res.json({ data: { success: true } }); + } catch (error) { + next(error); + } + } + + private async optOutContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const optedOut = await this.whatsappService.optOutContact(id); + + if (!optedOut) { + res.status(404).json({ error: 'Contact not found' }); + return; + } + + res.json({ data: { success: true } }); + } catch (error) { + next(error); + } + } + + private async addTagToContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { tag } = req.body; + + const contact = await this.whatsappService.addTagToContact(id, tag); + + if (!contact) { + res.status(404).json({ error: 'Contact not found' }); + return; + } + + res.json({ data: contact }); + } catch (error) { + next(error); + } + } + + private async removeTagFromContact(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id, tag } = req.params; + const contact = await this.whatsappService.removeTagFromContact(id, tag); + + if (!contact) { + res.status(404).json({ error: 'Contact not found' }); + return; + } + + res.json({ data: contact }); + } catch (error) { + next(error); + } + } + + // ============================================ + // MESSAGES + // ============================================ + + private async findMessages(req: Request, res: Response, next: NextFunction): Promise { + try { + const { accountId } = req.params; + const filters: MessageFilters = { + contactId: req.query.contactId as string, + direction: req.query.direction as string, + messageType: req.query.messageType as string, + status: req.query.status as string, + }; + + if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string); + if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string); + + const limit = parseInt(req.query.limit as string) || 50; + + const messages = await this.whatsappService.findMessages(accountId, filters, limit); + res.json({ data: messages, total: messages.length }); + } catch (error) { + next(error); + } + } + + private async findMessage(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const message = await this.whatsappService.findMessage(id); + + if (!message) { + res.status(404).json({ error: 'Message not found' }); + return; + } + + res.json({ data: message }); + } catch (error) { + next(error); + } + } + + private async findConversationMessages(req: Request, res: Response, next: NextFunction): Promise { + try { + const { contactId } = req.params; + const limit = parseInt(req.query.limit as string) || 100; + + const messages = await this.whatsappService.findConversationMessages(contactId, limit); + res.json({ data: messages, total: messages.length }); + } catch (error) { + next(error); + } + } + + private async createMessage(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { accountId, contactId, ...data } = req.body; + + const message = await this.whatsappService.createMessage(tenantId, accountId, contactId, data); + res.status(201).json({ data: message }); + } catch (error) { + next(error); + } + } + + private async updateMessageStatus(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { status, timestamp } = req.body; + + const message = await this.whatsappService.updateMessageStatus( + id, + status, + timestamp ? new Date(timestamp) : undefined + ); + + if (!message) { + res.status(404).json({ error: 'Message not found' }); + return; + } + + res.json({ data: message }); + } catch (error) { + next(error); + } + } + + // ============================================ + // TEMPLATES + // ============================================ + + private async findTemplates(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { accountId } = req.params; + const category = req.query.category as string | undefined; + + const templates = await this.whatsappService.findTemplates(tenantId, accountId, category); + res.json({ data: templates, total: templates.length }); + } catch (error) { + next(error); + } + } + + private async findApprovedTemplates(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { accountId } = req.params; + + const templates = await this.whatsappService.findApprovedTemplates(tenantId, accountId); + res.json({ data: templates, total: templates.length }); + } catch (error) { + next(error); + } + } + + private async findTemplate(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const template = await this.whatsappService.findTemplate(id); + + if (!template) { + res.status(404).json({ error: 'Template not found' }); + return; + } + + res.json({ data: template }); + } catch (error) { + next(error); + } + } + + private async createTemplate(req: Request, res: Response, next: NextFunction): Promise { + try { + const tenantId = req.headers['x-tenant-id'] as string; + const { accountId, ...data } = req.body; + + const template = await this.whatsappService.createTemplate(tenantId, accountId, data); + res.status(201).json({ data: template }); + } catch (error) { + next(error); + } + } + + private async updateTemplate(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const template = await this.whatsappService.updateTemplate(id, req.body); + + if (!template) { + res.status(404).json({ error: 'Template not found' }); + return; + } + + res.json({ data: template }); + } catch (error) { + next(error); + } + } + + private async updateTemplateStatus(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const { metaStatus, metaTemplateId, rejectionReason } = req.body; + + const template = await this.whatsappService.updateTemplateStatus( + id, + metaStatus, + metaTemplateId, + rejectionReason + ); + + if (!template) { + res.status(404).json({ error: 'Template not found' }); + return; + } + + res.json({ data: template }); + } catch (error) { + next(error); + } + } + + private async deactivateTemplate(req: Request, res: Response, next: NextFunction): Promise { + try { + const { id } = req.params; + const deactivated = await this.whatsappService.deactivateTemplate(id); + + if (!deactivated) { + res.status(404).json({ error: 'Template not found' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } + } +} diff --git a/src/modules/whatsapp/dto/index.ts b/src/modules/whatsapp/dto/index.ts new file mode 100644 index 0000000..03a2864 --- /dev/null +++ b/src/modules/whatsapp/dto/index.ts @@ -0,0 +1,14 @@ +export { + CreateAccountDto, + UpdateAccountDto, + UpdateAccountStatusDto, + CreateContactDto, + UpdateContactDto, + AddTagDto, + CreateMessageDto, + UpdateMessageStatusDto, + MessageErrorDto, + CreateTemplateDto, + UpdateTemplateDto, + UpdateTemplateStatusDto, +} from './whatsapp.dto'; diff --git a/src/modules/whatsapp/dto/whatsapp.dto.ts b/src/modules/whatsapp/dto/whatsapp.dto.ts new file mode 100644 index 0000000..6db7988 --- /dev/null +++ b/src/modules/whatsapp/dto/whatsapp.dto.ts @@ -0,0 +1,377 @@ +import { + IsString, + IsOptional, + IsBoolean, + IsNumber, + IsArray, + IsObject, + IsUUID, + MaxLength, + MinLength, + Min, + Matches, +} from 'class-validator'; + +// ============================================ +// ACCOUNT DTOs +// ============================================ + +export class CreateAccountDto { + @IsString() + @MaxLength(20) + @Matches(/^\+?[1-9]\d{1,14}$/, { message: 'Invalid phone number format' }) + phoneNumber: string; + + @IsString() + @MinLength(2) + @MaxLength(100) + displayName: string; + + @IsOptional() + @IsString() + @MaxLength(20) + businessId?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + phoneNumberId?: string; + + @IsOptional() + @IsString() + accessToken?: string; + + @IsOptional() + @IsString() + webhookSecret?: string; + + @IsOptional() + @IsObject() + businessProfile?: { + description?: string; + email?: string; + website?: string; + address?: string; + vertical?: string; + }; +} + +export class UpdateAccountDto { + @IsOptional() + @IsString() + @MaxLength(100) + displayName?: string; + + @IsOptional() + @IsString() + accessToken?: string; + + @IsOptional() + @IsString() + webhookSecret?: string; + + @IsOptional() + @IsObject() + businessProfile?: Record; + + @IsOptional() + @IsObject() + settings?: Record; +} + +export class UpdateAccountStatusDto { + @IsString() + @MaxLength(20) + status: string; +} + +// ============================================ +// CONTACT DTOs +// ============================================ + +export class CreateContactDto { + @IsUUID() + accountId: string; + + @IsString() + @MaxLength(20) + @Matches(/^\+?[1-9]\d{1,14}$/, { message: 'Invalid phone number format' }) + phoneNumber: string; + + @IsOptional() + @IsString() + @MaxLength(50) + waId?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + profileName?: string; + + @IsOptional() + @IsUUID() + customerId?: string; + + @IsOptional() + @IsUUID() + userId?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateContactDto { + @IsOptional() + @IsString() + @MaxLength(200) + profileName?: string; + + @IsOptional() + @IsUUID() + customerId?: string; + + @IsOptional() + @IsUUID() + userId?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + conversationStatus?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @IsOptional() + @IsString() + notes?: string; +} + +export class AddTagDto { + @IsString() + @MaxLength(50) + tag: string; +} + +// ============================================ +// MESSAGE DTOs +// ============================================ + +export class CreateMessageDto { + @IsUUID() + accountId: string; + + @IsUUID() + contactId: string; + + @IsString() + @MaxLength(10) + direction: string; + + @IsString() + @MaxLength(20) + messageType: string; + + @IsOptional() + @IsString() + content?: string; + + @IsOptional() + @IsString() + caption?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + mediaId?: string; + + @IsOptional() + @IsString() + mediaUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + mediaMimeType?: string; + + @IsOptional() + @IsUUID() + templateId?: string; + + @IsOptional() + @IsString() + @MaxLength(512) + templateName?: string; + + @IsOptional() + @IsArray() + templateVariables?: string[]; + + @IsOptional() + @IsString() + @MaxLength(30) + interactiveType?: string; + + @IsOptional() + @IsObject() + interactiveData?: Record; + + @IsOptional() + @IsString() + @MaxLength(100) + contextMessageId?: string; + + @IsOptional() + @IsObject() + metadata?: Record; +} + +export class UpdateMessageStatusDto { + @IsString() + @MaxLength(20) + status: string; + + @IsOptional() + @IsString() + timestamp?: string; +} + +export class MessageErrorDto { + @IsString() + @MaxLength(20) + errorCode: string; + + @IsString() + errorMessage: string; +} + +// ============================================ +// TEMPLATE DTOs +// ============================================ + +export class CreateTemplateDto { + @IsUUID() + accountId: string; + + @IsString() + @MinLength(1) + @MaxLength(512) + name: string; + + @IsString() + @MinLength(2) + @MaxLength(200) + displayName: string; + + @IsOptional() + @IsString() + description?: string; + + @IsString() + @MaxLength(30) + category: string; + + @IsOptional() + @IsString() + @MaxLength(10) + language?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + headerType?: string; + + @IsOptional() + @IsString() + headerText?: string; + + @IsOptional() + @IsString() + headerMediaUrl?: string; + + @IsString() + bodyText: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + bodyVariables?: string[]; + + @IsOptional() + @IsString() + @MaxLength(60) + footerText?: string; + + @IsOptional() + @IsArray() + buttons?: Record[]; +} + +export class UpdateTemplateDto { + @IsOptional() + @IsString() + @MaxLength(200) + displayName?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + headerType?: string; + + @IsOptional() + @IsString() + headerText?: string; + + @IsOptional() + @IsString() + headerMediaUrl?: string; + + @IsOptional() + @IsString() + bodyText?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + bodyVariables?: string[]; + + @IsOptional() + @IsString() + @MaxLength(60) + footerText?: string; + + @IsOptional() + @IsArray() + buttons?: Record[]; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdateTemplateStatusDto { + @IsString() + @MaxLength(20) + metaStatus: string; + + @IsOptional() + @IsString() + @MaxLength(50) + metaTemplateId?: string; + + @IsOptional() + @IsString() + rejectionReason?: string; +} diff --git a/src/modules/whatsapp/entities/account.entity.ts b/src/modules/whatsapp/entities/account.entity.ts new file mode 100644 index 0000000..85893f2 --- /dev/null +++ b/src/modules/whatsapp/entities/account.entity.ts @@ -0,0 +1,102 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +export type AccountStatus = 'pending' | 'active' | 'suspended' | 'disconnected'; + +@Entity({ name: 'accounts', schema: 'whatsapp' }) +@Unique(['tenantId', 'phoneNumber']) +export class WhatsAppAccount { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Index() + @Column({ name: 'phone_number', type: 'varchar', length: 20 }) + phoneNumber: string; + + @Column({ name: 'phone_number_id', type: 'varchar', length: 50 }) + phoneNumberId: string; + + @Column({ name: 'business_account_id', type: 'varchar', length: 50 }) + businessAccountId: string; + + @Column({ name: 'access_token', type: 'text', nullable: true }) + accessToken: string; + + @Column({ name: 'webhook_verify_token', type: 'varchar', length: 255, nullable: true }) + webhookVerifyToken: string; + + @Column({ name: 'webhook_secret', type: 'varchar', length: 255, nullable: true }) + webhookSecret: string; + + @Column({ name: 'business_name', type: 'varchar', length: 200, nullable: true }) + businessName: string; + + @Column({ name: 'business_description', type: 'text', nullable: true }) + businessDescription: string; + + @Column({ name: 'business_category', type: 'varchar', length: 100, nullable: true }) + businessCategory: string; + + @Column({ name: 'business_website', type: 'text', nullable: true }) + businessWebsite: string; + + @Column({ name: 'profile_picture_url', type: 'text', nullable: true }) + profilePictureUrl: string; + + @Column({ name: 'default_language', type: 'varchar', length: 10, default: 'es_MX' }) + defaultLanguage: string; + + @Column({ name: 'auto_reply_enabled', type: 'boolean', default: false }) + autoReplyEnabled: boolean; + + @Column({ name: 'auto_reply_message', type: 'text', nullable: true }) + autoReplyMessage: string; + + @Column({ name: 'business_hours', type: 'jsonb', default: {} }) + businessHours: Record; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: AccountStatus; + + @Column({ name: 'verified_at', type: 'timestamptz', nullable: true }) + verifiedAt: Date; + + @Column({ name: 'daily_message_limit', type: 'int', default: 1000 }) + dailyMessageLimit: number; + + @Column({ name: 'messages_sent_today', type: 'int', default: 0 }) + messagesSentToday: number; + + @Column({ name: 'last_limit_reset', type: 'timestamptz', nullable: true }) + lastLimitReset: Date; + + @Column({ name: 'total_messages_sent', type: 'bigint', default: 0 }) + totalMessagesSent: number; + + @Column({ name: 'total_messages_received', type: 'bigint', default: 0 }) + totalMessagesReceived: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; +} diff --git a/src/modules/whatsapp/entities/automation.entity.ts b/src/modules/whatsapp/entities/automation.entity.ts new file mode 100644 index 0000000..a8aeb81 --- /dev/null +++ b/src/modules/whatsapp/entities/automation.entity.ts @@ -0,0 +1,75 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; + +export type AutomationTriggerType = 'keyword' | 'first_message' | 'after_hours' | 'no_response' | 'webhook'; +export type AutomationActionType = 'send_message' | 'send_template' | 'assign_agent' | 'add_tag' | 'create_ticket'; + +@Entity({ name: 'automations', schema: 'whatsapp' }) +export class WhatsAppAutomation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'trigger_type', type: 'varchar', length: 30 }) + triggerType: AutomationTriggerType; + + @Column({ name: 'trigger_config', type: 'jsonb', default: {} }) + triggerConfig: Record; + + @Column({ name: 'action_type', type: 'varchar', length: 30 }) + actionType: AutomationActionType; + + @Column({ name: 'action_config', type: 'jsonb', default: {} }) + actionConfig: Record; + + @Column({ name: 'conditions', type: 'jsonb', default: [] }) + conditions: Record[]; + + @Index() + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'priority', type: 'int', default: 0 }) + priority: number; + + @Column({ name: 'trigger_count', type: 'int', default: 0 }) + triggerCount: number; + + @Column({ name: 'last_triggered_at', type: 'timestamptz', nullable: true }) + lastTriggeredAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; +} diff --git a/src/modules/whatsapp/entities/broadcast-recipient.entity.ts b/src/modules/whatsapp/entities/broadcast-recipient.entity.ts new file mode 100644 index 0000000..71fb0ad --- /dev/null +++ b/src/modules/whatsapp/entities/broadcast-recipient.entity.ts @@ -0,0 +1,69 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { Broadcast } from './broadcast.entity'; +import { WhatsAppContact } from './contact.entity'; +import { WhatsAppMessage } from './message.entity'; + +export type RecipientStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed'; + +@Entity({ name: 'broadcast_recipients', schema: 'whatsapp' }) +@Unique(['broadcastId', 'contactId']) +export class BroadcastRecipient { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'broadcast_id', type: 'uuid' }) + broadcastId: string; + + @Column({ name: 'contact_id', type: 'uuid' }) + contactId: string; + + @Column({ name: 'template_variables', type: 'jsonb', default: [] }) + templateVariables: any[]; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: RecipientStatus; + + @Column({ name: 'message_id', type: 'uuid', nullable: true }) + messageId: string; + + @Column({ name: 'error_code', type: 'varchar', length: 20, nullable: true }) + errorCode: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'sent_at', type: 'timestamptz', nullable: true }) + sentAt: Date; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => Broadcast, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'broadcast_id' }) + broadcast: Broadcast; + + @ManyToOne(() => WhatsAppContact, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contact_id' }) + contact: WhatsAppContact; + + @ManyToOne(() => WhatsAppMessage, { nullable: true }) + @JoinColumn({ name: 'message_id' }) + message: WhatsAppMessage; +} diff --git a/src/modules/whatsapp/entities/broadcast.entity.ts b/src/modules/whatsapp/entities/broadcast.entity.ts new file mode 100644 index 0000000..94f11cc --- /dev/null +++ b/src/modules/whatsapp/entities/broadcast.entity.ts @@ -0,0 +1,102 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; +import { WhatsAppTemplate } from './template.entity'; + +export type BroadcastStatus = 'draft' | 'scheduled' | 'sending' | 'completed' | 'cancelled' | 'failed'; +export type AudienceType = 'all' | 'segment' | 'custom' | 'file'; + +@Entity({ name: 'broadcasts', schema: 'whatsapp' }) +export class Broadcast { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Column({ name: 'name', type: 'varchar', length: 200 }) + name: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Column({ name: 'template_id', type: 'uuid' }) + templateId: string; + + @Column({ name: 'audience_type', type: 'varchar', length: 30 }) + audienceType: AudienceType; + + @Column({ name: 'audience_filter', type: 'jsonb', default: {} }) + audienceFilter: Record; + + @Column({ name: 'recipient_count', type: 'int', default: 0 }) + recipientCount: number; + + @Index() + @Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true }) + scheduledAt: Date; + + @Column({ name: 'timezone', type: 'varchar', length: 50, default: 'America/Mexico_City' }) + timezone: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'draft' }) + status: BroadcastStatus; + + @Column({ name: 'sent_count', type: 'int', default: 0 }) + sentCount: number; + + @Column({ name: 'delivered_count', type: 'int', default: 0 }) + deliveredCount: number; + + @Column({ name: 'read_count', type: 'int', default: 0 }) + readCount: number; + + @Column({ name: 'failed_count', type: 'int', default: 0 }) + failedCount: number; + + @Column({ name: 'reply_count', type: 'int', default: 0 }) + replyCount: number; + + @Column({ name: 'started_at', type: 'timestamptz', nullable: true }) + startedAt: Date; + + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt: Date; + + @Column({ name: 'estimated_cost', type: 'decimal', precision: 10, scale: 2, nullable: true }) + estimatedCost: number; + + @Column({ name: 'actual_cost', type: 'decimal', precision: 10, scale: 2, nullable: true }) + actualCost: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; + + @ManyToOne(() => WhatsAppTemplate) + @JoinColumn({ name: 'template_id' }) + template: WhatsAppTemplate; +} diff --git a/src/modules/whatsapp/entities/contact.entity.ts b/src/modules/whatsapp/entities/contact.entity.ts new file mode 100644 index 0000000..b3b6726 --- /dev/null +++ b/src/modules/whatsapp/entities/contact.entity.ts @@ -0,0 +1,99 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; + +export type ConversationStatus = 'active' | 'waiting' | 'resolved' | 'blocked'; +export type MessageDirection = 'inbound' | 'outbound'; + +@Entity({ name: 'contacts', schema: 'whatsapp' }) +@Unique(['accountId', 'phoneNumber']) +export class WhatsAppContact { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Index() + @Column({ name: 'phone_number', type: 'varchar', length: 20 }) + phoneNumber: string; + + @Column({ name: 'wa_id', type: 'varchar', length: 50, nullable: true }) + waId: string; + + @Column({ name: 'profile_name', type: 'varchar', length: 200, nullable: true }) + profileName: string; + + @Column({ name: 'profile_picture_url', type: 'text', nullable: true }) + profilePictureUrl: string; + + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId: string; + + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId: string; + + @Column({ name: 'conversation_status', type: 'varchar', length: 20, default: 'active' }) + conversationStatus: ConversationStatus; + + @Column({ name: 'last_message_at', type: 'timestamptz', nullable: true }) + lastMessageAt: Date; + + @Column({ name: 'last_message_direction', type: 'varchar', length: 10, nullable: true }) + lastMessageDirection: MessageDirection; + + @Column({ name: 'conversation_window_expires_at', type: 'timestamptz', nullable: true }) + conversationWindowExpiresAt: Date; + + @Column({ name: 'can_send_template_only', type: 'boolean', default: true }) + canSendTemplateOnly: boolean; + + @Index() + @Column({ name: 'opted_in', type: 'boolean', default: false }) + optedIn: boolean; + + @Column({ name: 'opted_in_at', type: 'timestamptz', nullable: true }) + optedInAt: Date; + + @Column({ name: 'opted_out', type: 'boolean', default: false }) + optedOut: boolean; + + @Column({ name: 'opted_out_at', type: 'timestamptz', nullable: true }) + optedOutAt: Date; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes: string; + + @Column({ name: 'total_messages_sent', type: 'int', default: 0 }) + totalMessagesSent: number; + + @Column({ name: 'total_messages_received', type: 'int', default: 0 }) + totalMessagesReceived: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; +} diff --git a/src/modules/whatsapp/entities/conversation.entity.ts b/src/modules/whatsapp/entities/conversation.entity.ts new file mode 100644 index 0000000..7eef57b --- /dev/null +++ b/src/modules/whatsapp/entities/conversation.entity.ts @@ -0,0 +1,92 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; +import { WhatsAppContact } from './contact.entity'; + +export type WAConversationStatus = 'open' | 'pending' | 'resolved' | 'closed'; +export type WAConversationPriority = 'low' | 'normal' | 'high' | 'urgent'; + +@Entity({ name: 'conversations', schema: 'whatsapp' }) +export class WhatsAppConversation { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Index() + @Column({ name: 'contact_id', type: 'uuid' }) + contactId: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'open' }) + status: WAConversationStatus; + + @Column({ name: 'priority', type: 'varchar', length: 20, default: 'normal' }) + priority: WAConversationPriority; + + @Index() + @Column({ name: 'assigned_to', type: 'uuid', nullable: true }) + assignedTo: string; + + @Column({ name: 'assigned_at', type: 'timestamptz', nullable: true }) + assignedAt: Date; + + @Column({ name: 'team_id', type: 'uuid', nullable: true }) + teamId: string; + + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: string; + + @Column({ name: 'tags', type: 'text', array: true, default: [] }) + tags: string[]; + + @Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true }) + contextType: string; + + @Column({ name: 'context_id', type: 'uuid', nullable: true }) + contextId: string; + + @Column({ name: 'first_response_at', type: 'timestamptz', nullable: true }) + firstResponseAt: Date; + + @Column({ name: 'resolved_at', type: 'timestamptz', nullable: true }) + resolvedAt: Date; + + @Column({ name: 'message_count', type: 'int', default: 0 }) + messageCount: number; + + @Column({ name: 'unread_count', type: 'int', default: 0 }) + unreadCount: number; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; + + @ManyToOne(() => WhatsAppContact, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contact_id' }) + contact: WhatsAppContact; +} diff --git a/src/modules/whatsapp/entities/index.ts b/src/modules/whatsapp/entities/index.ts new file mode 100644 index 0000000..4eafbfe --- /dev/null +++ b/src/modules/whatsapp/entities/index.ts @@ -0,0 +1,10 @@ +export { WhatsAppAccount, AccountStatus } from './account.entity'; +export { WhatsAppContact, ConversationStatus } from './contact.entity'; +export { WhatsAppMessage, MessageType, MessageStatus, MessageDirection, CostCategory } from './message.entity'; +export { WhatsAppTemplate, TemplateCategory, TemplateStatus, HeaderType } from './template.entity'; +export { WhatsAppConversation, WAConversationStatus, WAConversationPriority } from './conversation.entity'; +export { MessageStatusUpdate } from './message-status-update.entity'; +export { QuickReply } from './quick-reply.entity'; +export { WhatsAppAutomation, AutomationTriggerType, AutomationActionType } from './automation.entity'; +export { Broadcast, BroadcastStatus, AudienceType } from './broadcast.entity'; +export { BroadcastRecipient, RecipientStatus } from './broadcast-recipient.entity'; diff --git a/src/modules/whatsapp/entities/message-status-update.entity.ts b/src/modules/whatsapp/entities/message-status-update.entity.ts new file mode 100644 index 0000000..a729d27 --- /dev/null +++ b/src/modules/whatsapp/entities/message-status-update.entity.ts @@ -0,0 +1,45 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppMessage } from './message.entity'; + +@Entity({ name: 'message_status_updates', schema: 'whatsapp' }) +export class MessageStatusUpdate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'message_id', type: 'uuid' }) + messageId: string; + + @Column({ name: 'status', type: 'varchar', length: 20 }) + status: string; + + @Column({ name: 'previous_status', type: 'varchar', length: 20, nullable: true }) + previousStatus: string; + + @Column({ name: 'error_code', type: 'varchar', length: 20, nullable: true }) + errorCode: string; + + @Column({ name: 'error_title', type: 'varchar', length: 200, nullable: true }) + errorTitle: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'meta_timestamp', type: 'timestamptz', nullable: true }) + metaTimestamp: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => WhatsAppMessage, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'message_id' }) + message: WhatsAppMessage; +} diff --git a/src/modules/whatsapp/entities/message.entity.ts b/src/modules/whatsapp/entities/message.entity.ts new file mode 100644 index 0000000..d51fc47 --- /dev/null +++ b/src/modules/whatsapp/entities/message.entity.ts @@ -0,0 +1,137 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; +import { WhatsAppContact } from './contact.entity'; + +export type MessageType = 'text' | 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'location' | 'contacts' | 'interactive' | 'template' | 'reaction'; +export type MessageStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed'; +export type MessageDirection = 'inbound' | 'outbound'; +export type CostCategory = 'utility' | 'authentication' | 'marketing'; + +@Entity({ name: 'messages', schema: 'whatsapp' }) +export class WhatsAppMessage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Index() + @Column({ name: 'contact_id', type: 'uuid' }) + contactId: string; + + @Index() + @Column({ name: 'conversation_id', type: 'uuid', nullable: true }) + conversationId: string; + + @Index() + @Column({ name: 'wa_message_id', type: 'varchar', length: 100, nullable: true }) + waMessageId: string; + + @Column({ name: 'wa_conversation_id', type: 'varchar', length: 100, nullable: true }) + waConversationId: string; + + @Index() + @Column({ name: 'direction', type: 'varchar', length: 10 }) + direction: MessageDirection; + + @Column({ name: 'message_type', type: 'varchar', length: 20 }) + messageType: MessageType; + + @Column({ name: 'content', type: 'text', nullable: true }) + content: string; + + @Column({ name: 'caption', type: 'text', nullable: true }) + caption: string; + + @Column({ name: 'media_id', type: 'varchar', length: 100, nullable: true }) + mediaId: string; + + @Column({ name: 'media_url', type: 'text', nullable: true }) + mediaUrl: string; + + @Column({ name: 'media_mime_type', type: 'varchar', length: 100, nullable: true }) + mediaMimeType: string; + + @Column({ name: 'media_sha256', type: 'varchar', length: 64, nullable: true }) + mediaSha256: string; + + @Column({ name: 'media_size_bytes', type: 'int', nullable: true }) + mediaSizeBytes: number; + + @Column({ name: 'template_id', type: 'uuid', nullable: true }) + templateId: string; + + @Column({ name: 'template_name', type: 'varchar', length: 512, nullable: true }) + templateName: string; + + @Column({ name: 'template_variables', type: 'jsonb', default: [] }) + templateVariables: string[]; + + @Column({ name: 'interactive_type', type: 'varchar', length: 30, nullable: true }) + interactiveType: string; + + @Column({ name: 'interactive_data', type: 'jsonb', default: {} }) + interactiveData: Record; + + @Column({ name: 'context_message_id', type: 'varchar', length: 100, nullable: true }) + contextMessageId: string; + + @Column({ name: 'quoted_message_id', type: 'uuid', nullable: true }) + quotedMessageId: string; + + @Index() + @Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' }) + status: MessageStatus; + + @Column({ name: 'status_updated_at', type: 'timestamptz', nullable: true }) + statusUpdatedAt: Date; + + @Column({ name: 'error_code', type: 'varchar', length: 20, nullable: true }) + errorCode: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string; + + @Column({ name: 'is_billable', type: 'boolean', default: false }) + isBillable: boolean; + + @Column({ name: 'cost_category', type: 'varchar', length: 30, nullable: true }) + costCategory: CostCategory; + + @Column({ name: 'metadata', type: 'jsonb', default: {} }) + metadata: Record; + + @Index() + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'sent_at', type: 'timestamptz', nullable: true }) + sentAt: Date; + + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date; + + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; + + @ManyToOne(() => WhatsAppContact, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'contact_id' }) + contact: WhatsAppContact; +} diff --git a/src/modules/whatsapp/entities/quick-reply.entity.ts b/src/modules/whatsapp/entities/quick-reply.entity.ts new file mode 100644 index 0000000..ddb14bf --- /dev/null +++ b/src/modules/whatsapp/entities/quick-reply.entity.ts @@ -0,0 +1,67 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; + +@Entity({ name: 'quick_replies', schema: 'whatsapp' }) +@Unique(['tenantId', 'shortcut']) +export class QuickReply { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'account_id', type: 'uuid', nullable: true }) + accountId: string; + + @Index() + @Column({ name: 'shortcut', type: 'varchar', length: 50 }) + shortcut: string; + + @Column({ name: 'title', type: 'varchar', length: 200 }) + title: string; + + @Column({ name: 'category', type: 'varchar', length: 50, nullable: true }) + category: string; + + @Column({ name: 'message_type', type: 'varchar', length: 20, default: 'text' }) + messageType: string; + + @Column({ name: 'content', type: 'text' }) + content: string; + + @Column({ name: 'media_url', type: 'text', nullable: true }) + mediaUrl: string; + + @Column({ name: 'usage_count', type: 'int', default: 0 }) + usageCount: number; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: Date; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'created_by', type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => WhatsAppAccount, { nullable: true, onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; +} diff --git a/src/modules/whatsapp/entities/template.entity.ts b/src/modules/whatsapp/entities/template.entity.ts new file mode 100644 index 0000000..1100d5d --- /dev/null +++ b/src/modules/whatsapp/entities/template.entity.ts @@ -0,0 +1,106 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; +import { WhatsAppAccount } from './account.entity'; + +export type TemplateCategory = 'MARKETING' | 'UTILITY' | 'AUTHENTICATION'; +export type TemplateStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'PAUSED' | 'DISABLED'; +export type HeaderType = 'TEXT' | 'IMAGE' | 'VIDEO' | 'DOCUMENT'; + +@Entity({ name: 'templates', schema: 'whatsapp' }) +@Unique(['accountId', 'name', 'language']) +export class WhatsAppTemplate { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ name: 'account_id', type: 'uuid' }) + accountId: string; + + @Index() + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'name', type: 'varchar', length: 512 }) + name: string; + + @Column({ name: 'display_name', type: 'varchar', length: 200 }) + displayName: string; + + @Column({ name: 'description', type: 'text', nullable: true }) + description: string; + + @Index() + @Column({ name: 'category', type: 'varchar', length: 30 }) + category: TemplateCategory; + + @Column({ name: 'language', type: 'varchar', length: 10, default: 'es_MX' }) + language: string; + + @Column({ name: 'header_type', type: 'varchar', length: 20, nullable: true }) + headerType: HeaderType; + + @Column({ name: 'header_text', type: 'text', nullable: true }) + headerText: string; + + @Column({ name: 'header_media_url', type: 'text', nullable: true }) + headerMediaUrl: string; + + @Column({ name: 'body_text', type: 'text' }) + bodyText: string; + + @Column({ name: 'body_variables', type: 'text', array: true, default: [] }) + bodyVariables: string[]; + + @Column({ name: 'footer_text', type: 'varchar', length: 60, nullable: true }) + footerText: string; + + @Column({ name: 'buttons', type: 'jsonb', default: [] }) + buttons: Record[]; + + @Column({ name: 'meta_template_id', type: 'varchar', length: 50, nullable: true }) + metaTemplateId: string; + + @Index() + @Column({ name: 'meta_status', type: 'varchar', length: 20, default: 'PENDING' }) + metaStatus: TemplateStatus; + + @Column({ name: 'rejection_reason', type: 'text', nullable: true }) + rejectionReason: string; + + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive: boolean; + + @Column({ name: 'usage_count', type: 'int', default: 0 }) + usageCount: number; + + @Column({ name: 'last_used_at', type: 'timestamptz', nullable: true }) + lastUsedAt: Date; + + @Column({ name: 'version', type: 'int', default: 1 }) + version: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; + + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt: Date; + + @Column({ name: 'approved_at', type: 'timestamptz', nullable: true }) + approvedAt: Date; + + @ManyToOne(() => WhatsAppAccount, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'account_id' }) + account: WhatsAppAccount; +} diff --git a/src/modules/whatsapp/index.ts b/src/modules/whatsapp/index.ts new file mode 100644 index 0000000..aa71081 --- /dev/null +++ b/src/modules/whatsapp/index.ts @@ -0,0 +1,5 @@ +export { WhatsAppModule, WhatsAppModuleOptions } from './whatsapp.module'; +export * from './entities'; +export * from './services'; +export * from './controllers'; +export * from './dto'; diff --git a/src/modules/whatsapp/services/index.ts b/src/modules/whatsapp/services/index.ts new file mode 100644 index 0000000..1986065 --- /dev/null +++ b/src/modules/whatsapp/services/index.ts @@ -0,0 +1 @@ +export { WhatsAppService, MessageFilters, ContactFilters } from './whatsapp.service'; diff --git a/src/modules/whatsapp/services/whatsapp.service.ts b/src/modules/whatsapp/services/whatsapp.service.ts new file mode 100644 index 0000000..186d3a5 --- /dev/null +++ b/src/modules/whatsapp/services/whatsapp.service.ts @@ -0,0 +1,464 @@ +import { Repository, FindOptionsWhere, LessThan, Between, In } from 'typeorm'; +import { WhatsAppAccount, WhatsAppContact, WhatsAppMessage, WhatsAppTemplate } from '../entities'; + +export interface MessageFilters { + contactId?: string; + direction?: string; + messageType?: string; + status?: string; + startDate?: Date; + endDate?: Date; +} + +export interface ContactFilters { + conversationStatus?: string; + optedIn?: boolean; + tag?: string; +} + +export class WhatsAppService { + constructor( + private readonly accountRepository: Repository, + private readonly contactRepository: Repository, + private readonly messageRepository: Repository, + private readonly templateRepository: Repository + ) {} + + // ============================================ + // ACCOUNTS + // ============================================ + + async findAllAccounts(tenantId: string): Promise { + return this.accountRepository.find({ + where: { tenantId }, + order: { displayName: 'ASC' }, + }); + } + + async findActiveAccounts(tenantId: string): Promise { + return this.accountRepository.find({ + where: { tenantId, status: 'active' }, + order: { displayName: 'ASC' }, + }); + } + + async findAccount(id: string): Promise { + return this.accountRepository.findOne({ where: { id } }); + } + + async findAccountByPhoneNumber( + tenantId: string, + phoneNumber: string + ): Promise { + return this.accountRepository.findOne({ where: { tenantId, phoneNumber } }); + } + + async createAccount( + tenantId: string, + data: Partial, + createdBy?: string + ): Promise { + const account = this.accountRepository.create({ + ...data, + tenantId, + createdBy, + status: 'pending', + }); + return this.accountRepository.save(account); + } + + async updateAccount(id: string, data: Partial): Promise { + const account = await this.findAccount(id); + if (!account) return null; + + Object.assign(account, data); + return this.accountRepository.save(account); + } + + async updateAccountStatus(id: string, status: string): Promise { + const result = await this.accountRepository.update(id, { status: status as any }); + return (result.affected ?? 0) > 0; + } + + async incrementMessageCount(accountId: string, direction: 'sent' | 'received'): Promise { + const field = direction === 'sent' ? 'totalMessagesSent' : 'totalMessagesReceived'; + await this.accountRepository + .createQueryBuilder() + .update() + .set({ [field]: () => `${field} + 1` }) + .where('id = :id', { id: accountId }) + .execute(); + } + + // ============================================ + // CONTACTS + // ============================================ + + async findContacts( + tenantId: string, + accountId: string, + filters: ContactFilters = {}, + limit: number = 50 + ): Promise { + const where: FindOptionsWhere = { tenantId, accountId }; + + if (filters.conversationStatus) { + where.conversationStatus = filters.conversationStatus as any; + } + if (filters.optedIn !== undefined) { + where.optedIn = filters.optedIn; + } + + return this.contactRepository.find({ + where, + order: { lastMessageAt: 'DESC' }, + take: limit, + }); + } + + async findContact(id: string): Promise { + return this.contactRepository.findOne({ where: { id } }); + } + + async findContactByPhone(accountId: string, phoneNumber: string): Promise { + return this.contactRepository.findOne({ where: { accountId, phoneNumber } }); + } + + async createContact( + tenantId: string, + accountId: string, + data: Partial + ): Promise { + const contact = this.contactRepository.create({ + ...data, + tenantId, + accountId, + }); + return this.contactRepository.save(contact); + } + + async updateContact(id: string, data: Partial): Promise { + const contact = await this.findContact(id); + if (!contact) return null; + + Object.assign(contact, data); + return this.contactRepository.save(contact); + } + + async updateContactConversationWindow(id: string, expiresAt: Date): Promise { + await this.contactRepository.update(id, { + conversationWindowExpiresAt: expiresAt, + canSendTemplateOnly: false, + }); + } + + async expireConversationWindows(): Promise { + const now = new Date(); + const result = await this.contactRepository.update( + { conversationWindowExpiresAt: LessThan(now), canSendTemplateOnly: false }, + { canSendTemplateOnly: true } + ); + return result.affected ?? 0; + } + + async optInContact(id: string): Promise { + const result = await this.contactRepository.update(id, { + optedIn: true, + optedInAt: new Date(), + optedOut: false, + optedOutAt: undefined, + }); + return (result.affected ?? 0) > 0; + } + + async optOutContact(id: string): Promise { + const result = await this.contactRepository.update(id, { + optedOut: true, + optedOutAt: new Date(), + }); + return (result.affected ?? 0) > 0; + } + + async addTagToContact(id: string, tag: string): Promise { + const contact = await this.findContact(id); + if (!contact) return null; + + if (!contact.tags.includes(tag)) { + contact.tags.push(tag); + return this.contactRepository.save(contact); + } + return contact; + } + + async removeTagFromContact(id: string, tag: string): Promise { + const contact = await this.findContact(id); + if (!contact) return null; + + contact.tags = contact.tags.filter((t) => t !== tag); + return this.contactRepository.save(contact); + } + + // ============================================ + // MESSAGES + // ============================================ + + async findMessages( + accountId: string, + filters: MessageFilters = {}, + limit: number = 50 + ): Promise { + const where: FindOptionsWhere = { accountId }; + + if (filters.contactId) where.contactId = filters.contactId; + if (filters.direction) where.direction = filters.direction as any; + if (filters.messageType) where.messageType = filters.messageType as any; + if (filters.status) where.status = filters.status as any; + + if (filters.startDate && filters.endDate) { + where.createdAt = Between(filters.startDate, filters.endDate); + } + + return this.messageRepository.find({ + where, + order: { createdAt: 'DESC' }, + take: limit, + relations: ['contact'], + }); + } + + async findMessage(id: string): Promise { + return this.messageRepository.findOne({ + where: { id }, + relations: ['contact'], + }); + } + + async findMessageByWaId(waMessageId: string): Promise { + return this.messageRepository.findOne({ where: { waMessageId } }); + } + + async findConversationMessages( + contactId: string, + limit: number = 100 + ): Promise { + return this.messageRepository.find({ + where: { contactId }, + order: { createdAt: 'ASC' }, + take: limit, + }); + } + + async createMessage( + tenantId: string, + accountId: string, + contactId: string, + data: Partial + ): Promise { + const message = this.messageRepository.create({ + ...data, + tenantId, + accountId, + contactId, + status: 'pending', + }); + + const savedMessage = await this.messageRepository.save(message); + + // Update contact stats + const direction = data.direction; + if (direction) { + const field = direction === 'outbound' ? 'totalMessagesSent' : 'totalMessagesReceived'; + await this.contactRepository + .createQueryBuilder() + .update() + .set({ + [field]: () => `${field} + 1`, + lastMessageAt: new Date(), + lastMessageDirection: direction, + }) + .where('id = :id', { id: contactId }) + .execute(); + + // Update account stats + await this.incrementMessageCount(accountId, direction === 'outbound' ? 'sent' : 'received'); + } + + return savedMessage; + } + + async updateMessageStatus( + id: string, + status: string, + timestamp?: Date + ): Promise { + const message = await this.findMessage(id); + if (!message) return null; + + message.status = status as any; + message.statusUpdatedAt = timestamp || new Date(); + + if (status === 'sent' && !message.sentAt) message.sentAt = timestamp || new Date(); + if (status === 'delivered' && !message.deliveredAt) message.deliveredAt = timestamp || new Date(); + if (status === 'read' && !message.readAt) message.readAt = timestamp || new Date(); + + return this.messageRepository.save(message); + } + + async updateMessageError(id: string, errorCode: string, errorMessage: string): Promise { + await this.messageRepository.update(id, { + status: 'failed', + errorCode, + errorMessage, + statusUpdatedAt: new Date(), + }); + } + + // ============================================ + // TEMPLATES + // ============================================ + + async findTemplates( + tenantId: string, + accountId: string, + category?: string + ): Promise { + const where: FindOptionsWhere = { tenantId, accountId, isActive: true }; + if (category) where.category = category as any; + + return this.templateRepository.find({ + where, + order: { name: 'ASC' }, + }); + } + + async findApprovedTemplates( + tenantId: string, + accountId: string + ): Promise { + return this.templateRepository.find({ + where: { tenantId, accountId, metaStatus: 'APPROVED', isActive: true }, + order: { name: 'ASC' }, + }); + } + + async findTemplate(id: string): Promise { + return this.templateRepository.findOne({ where: { id } }); + } + + async findTemplateByName( + accountId: string, + name: string, + language: string = 'es_MX' + ): Promise { + return this.templateRepository.findOne({ where: { accountId, name, language } }); + } + + async createTemplate( + tenantId: string, + accountId: string, + data: Partial + ): Promise { + const template = this.templateRepository.create({ + ...data, + tenantId, + accountId, + metaStatus: 'PENDING', + }); + return this.templateRepository.save(template); + } + + async updateTemplate( + id: string, + data: Partial + ): Promise { + const template = await this.findTemplate(id); + if (!template) return null; + + // Increment version on update + Object.assign(template, data, { version: template.version + 1 }); + return this.templateRepository.save(template); + } + + async updateTemplateStatus( + id: string, + metaStatus: string, + metaTemplateId?: string, + rejectionReason?: string + ): Promise { + const template = await this.findTemplate(id); + if (!template) return null; + + template.metaStatus = metaStatus as any; + if (metaTemplateId) template.metaTemplateId = metaTemplateId; + if (rejectionReason) template.rejectionReason = rejectionReason; + if (metaStatus === 'APPROVED') template.approvedAt = new Date(); + + return this.templateRepository.save(template); + } + + async incrementTemplateUsage(id: string): Promise { + await this.templateRepository + .createQueryBuilder() + .update() + .set({ + usageCount: () => 'usage_count + 1', + lastUsedAt: new Date(), + }) + .where('id = :id', { id }) + .execute(); + } + + async deactivateTemplate(id: string): Promise { + const result = await this.templateRepository.update(id, { isActive: false }); + return (result.affected ?? 0) > 0; + } + + // ============================================ + // STATISTICS + // ============================================ + + async getAccountStats( + accountId: string, + startDate: Date, + endDate: Date + ): Promise<{ + totalMessages: number; + sent: number; + received: number; + delivered: number; + read: number; + failed: number; + }> { + const stats = await this.messageRepository + .createQueryBuilder('msg') + .select('COUNT(*)', 'total') + .addSelect("SUM(CASE WHEN msg.direction = 'outbound' THEN 1 ELSE 0 END)", 'sent') + .addSelect("SUM(CASE WHEN msg.direction = 'inbound' THEN 1 ELSE 0 END)", 'received') + .addSelect("SUM(CASE WHEN msg.status = 'delivered' THEN 1 ELSE 0 END)", 'delivered') + .addSelect("SUM(CASE WHEN msg.status = 'read' THEN 1 ELSE 0 END)", 'readCount') + .addSelect("SUM(CASE WHEN msg.status = 'failed' THEN 1 ELSE 0 END)", 'failed') + .where('msg.account_id = :accountId', { accountId }) + .andWhere('msg.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getRawOne(); + + return { + totalMessages: parseInt(stats?.total) || 0, + sent: parseInt(stats?.sent) || 0, + received: parseInt(stats?.received) || 0, + delivered: parseInt(stats?.delivered) || 0, + read: parseInt(stats?.readCount) || 0, + failed: parseInt(stats?.failed) || 0, + }; + } + + async getContactsWithExpiredWindow(accountId: string): Promise { + const now = new Date(); + return this.contactRepository.find({ + where: { + accountId, + conversationWindowExpiresAt: LessThan(now), + canSendTemplateOnly: false, + }, + }); + } +} diff --git a/src/modules/whatsapp/whatsapp.module.ts b/src/modules/whatsapp/whatsapp.module.ts new file mode 100644 index 0000000..891f388 --- /dev/null +++ b/src/modules/whatsapp/whatsapp.module.ts @@ -0,0 +1,58 @@ +import { Router } from 'express'; +import { DataSource } from 'typeorm'; +import { WhatsAppService } from './services'; +import { WhatsAppController } from './controllers'; +import { + WhatsAppAccount, + WhatsAppContact, + WhatsAppMessage, + WhatsAppTemplate, +} from './entities'; + +export interface WhatsAppModuleOptions { + dataSource: DataSource; + basePath?: string; +} + +export class WhatsAppModule { + public router: Router; + public whatsappService: WhatsAppService; + private dataSource: DataSource; + private basePath: string; + + constructor(options: WhatsAppModuleOptions) { + this.dataSource = options.dataSource; + this.basePath = options.basePath || ''; + this.router = Router(); + this.initializeServices(); + this.initializeRoutes(); + } + + private initializeServices(): void { + const accountRepository = this.dataSource.getRepository(WhatsAppAccount); + const contactRepository = this.dataSource.getRepository(WhatsAppContact); + const messageRepository = this.dataSource.getRepository(WhatsAppMessage); + const templateRepository = this.dataSource.getRepository(WhatsAppTemplate); + + this.whatsappService = new WhatsAppService( + accountRepository, + contactRepository, + messageRepository, + templateRepository + ); + } + + private initializeRoutes(): void { + const whatsappController = new WhatsAppController(this.whatsappService); + this.router.use(`${this.basePath}/whatsapp`, whatsappController.router); + } + + static getEntities(): Function[] { + return [ + WhatsAppAccount, + WhatsAppContact, + WhatsAppMessage, + WhatsAppTemplate, + ]; + } +} 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/feature-flags.service.ts b/src/shared/services/feature-flags.service.ts new file mode 100644 index 0000000..005fedb --- /dev/null +++ b/src/shared/services/feature-flags.service.ts @@ -0,0 +1,195 @@ +/** + * Feature Flags Service + * Permite activar/desactivar funcionalidades por tenant, usuario o porcentaje + */ + +export interface FeatureFlag { + enabled: boolean; + enabledTenants?: string[]; + disabledTenants?: string[]; + enabledUsers?: string[]; + disabledUsers?: string[]; + rolloutPercentage?: number; + description?: string; + metadata?: Record; +} + +export interface FeatureFlagContext { + tenantId?: string; + userId?: string; + profileCode?: string; + platform?: string; +} + +export class FeatureFlagService { + private flags: Map = new Map(); + private static instance: FeatureFlagService; + + private constructor() { + this.loadDefaultFlags(); + } + + static getInstance(): FeatureFlagService { + if (!FeatureFlagService.instance) { + FeatureFlagService.instance = new FeatureFlagService(); + } + return FeatureFlagService.instance; + } + + private loadDefaultFlags(): void { + // Flags por defecto + this.flags.set('mobile_app_enabled', { + enabled: true, + description: 'Habilita el acceso a la aplicacion movil', + }); + + this.flags.set('biometric_auth', { + enabled: true, + description: 'Habilita autenticacion biometrica', + }); + + this.flags.set('offline_mode', { + enabled: true, + description: 'Habilita modo offline en la app movil', + }); + + this.flags.set('payment_terminals', { + enabled: true, + description: 'Habilita integracion con terminales de pago', + }); + + this.flags.set('geofencing', { + enabled: true, + description: 'Habilita validacion de geofencing', + }); + + this.flags.set('push_notifications', { + enabled: true, + description: 'Habilita notificaciones push', + }); + + this.flags.set('usage_billing', { + enabled: true, + description: 'Habilita facturacion por uso', + }); + } + + async isEnabled(flagName: string, context?: FeatureFlagContext): Promise { + const flag = this.flags.get(flagName); + + if (!flag) { + return false; + } + + // Global flag deshabilitado + if (!flag.enabled) { + return false; + } + + // Tenant especificamente deshabilitado + if (context?.tenantId && flag.disabledTenants?.includes(context.tenantId)) { + return false; + } + + // Tenant especificamente habilitado (lista blanca) + if (context?.tenantId && flag.enabledTenants?.length) { + if (!flag.enabledTenants.includes(context.tenantId)) { + return false; + } + } + + // Usuario especificamente deshabilitado + if (context?.userId && flag.disabledUsers?.includes(context.userId)) { + return false; + } + + // Usuario especificamente habilitado (para beta testing) + if (context?.userId && flag.enabledUsers?.length) { + if (!flag.enabledUsers.includes(context.userId)) { + // Si hay lista de usuarios habilitados y este no esta, verificar rollout + if (flag.rolloutPercentage === undefined) { + return false; + } + } else { + return true; + } + } + + // Percentage rollout + if (flag.rolloutPercentage !== undefined && flag.rolloutPercentage < 100) { + const identifier = context?.userId || context?.tenantId || flagName; + const hash = this.hashString(`${flagName}-${identifier}`); + return (hash % 100) < flag.rolloutPercentage; + } + + return true; + } + + async setFlag(flagName: string, config: FeatureFlag): Promise { + this.flags.set(flagName, config); + // Aqui se podria persistir en BD si es necesario + } + + async getFlag(flagName: string): Promise { + return this.flags.get(flagName); + } + + async getAllFlags(): Promise> { + return new Map(this.flags); + } + + async updateFlag(flagName: string, updates: Partial): Promise { + const existing = this.flags.get(flagName); + if (existing) { + this.flags.set(flagName, { ...existing, ...updates }); + } + } + + async deleteFlag(flagName: string): Promise { + this.flags.delete(flagName); + } + + async enableForTenant(flagName: string, tenantId: string): Promise { + const flag = this.flags.get(flagName); + if (flag) { + const enabledTenants = flag.enabledTenants || []; + if (!enabledTenants.includes(tenantId)) { + enabledTenants.push(tenantId); + } + flag.enabledTenants = enabledTenants; + + // Remover de deshabilitados si estaba + if (flag.disabledTenants) { + flag.disabledTenants = flag.disabledTenants.filter((t) => t !== tenantId); + } + } + } + + async disableForTenant(flagName: string, tenantId: string): Promise { + const flag = this.flags.get(flagName); + if (flag) { + const disabledTenants = flag.disabledTenants || []; + if (!disabledTenants.includes(tenantId)) { + disabledTenants.push(tenantId); + } + flag.disabledTenants = disabledTenants; + + // Remover de habilitados si estaba + if (flag.enabledTenants) { + flag.enabledTenants = flag.enabledTenants.filter((t) => t !== tenantId); + } + } + } + + private hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) - hash) + str.charCodeAt(i); + hash |= 0; // Convert to 32bit integer + } + return Math.abs(hash); + } +} + +// Export singleton instance +export const featureFlagService = FeatureFlagService.getInstance(); diff --git a/src/shared/services/index.ts b/src/shared/services/index.ts new file mode 100644 index 0000000..0ea3523 --- /dev/null +++ b/src/shared/services/index.ts @@ -0,0 +1,6 @@ +export { + FeatureFlagService, + FeatureFlag, + FeatureFlagContext, + featureFlagService, +} from './feature-flags.service'; 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/circuit-breaker.ts b/src/shared/utils/circuit-breaker.ts new file mode 100644 index 0000000..41053b2 --- /dev/null +++ b/src/shared/utils/circuit-breaker.ts @@ -0,0 +1,158 @@ +/** + * Circuit Breaker Pattern Implementation + * Previene llamadas a servicios externos cuando estos estan fallando + */ + +export class CircuitBreakerOpenError extends Error { + constructor(public readonly circuitName: string) { + super(`Circuit breaker '${circuitName}' is OPEN. Service temporarily unavailable.`); + this.name = 'CircuitBreakerOpenError'; + } +} + +export type CircuitBreakerState = 'CLOSED' | 'OPEN' | 'HALF_OPEN'; + +export interface CircuitBreakerOptions { + failureThreshold?: number; + resetTimeout?: number; + halfOpenRequests?: number; + onStateChange?: (name: string, from: CircuitBreakerState, to: CircuitBreakerState) => void; +} + +export class CircuitBreaker { + private failures: number = 0; + private successes: number = 0; + private lastFailureTime: number = 0; + private state: CircuitBreakerState = 'CLOSED'; + private halfOpenAttempts: number = 0; + + private readonly failureThreshold: number; + private readonly resetTimeout: number; + private readonly halfOpenRequests: number; + private readonly onStateChange?: (name: string, from: CircuitBreakerState, to: CircuitBreakerState) => void; + + constructor( + private readonly name: string, + options: CircuitBreakerOptions = {} + ) { + this.failureThreshold = options.failureThreshold ?? 5; + this.resetTimeout = options.resetTimeout ?? 60000; // 1 minuto + this.halfOpenRequests = options.halfOpenRequests ?? 3; + this.onStateChange = options.onStateChange; + } + + async execute(fn: () => Promise): Promise { + if (this.state === 'OPEN') { + if (Date.now() - this.lastFailureTime >= this.resetTimeout) { + this.transitionTo('HALF_OPEN'); + } else { + throw new CircuitBreakerOpenError(this.name); + } + } + + if (this.state === 'HALF_OPEN' && this.halfOpenAttempts >= this.halfOpenRequests) { + throw new CircuitBreakerOpenError(this.name); + } + + try { + if (this.state === 'HALF_OPEN') { + this.halfOpenAttempts++; + } + + const result = await fn(); + this.onSuccess(); + return result; + } catch (error) { + this.onFailure(); + throw error; + } + } + + private onSuccess(): void { + if (this.state === 'HALF_OPEN') { + this.successes++; + if (this.successes >= this.halfOpenRequests) { + this.transitionTo('CLOSED'); + } + } else { + this.failures = 0; + } + } + + private onFailure(): void { + this.failures++; + this.lastFailureTime = Date.now(); + + if (this.state === 'HALF_OPEN') { + this.transitionTo('OPEN'); + } else if (this.failures >= this.failureThreshold) { + this.transitionTo('OPEN'); + } + } + + private transitionTo(newState: CircuitBreakerState): void { + const oldState = this.state; + this.state = newState; + + if (newState === 'CLOSED') { + this.failures = 0; + this.successes = 0; + this.halfOpenAttempts = 0; + } else if (newState === 'HALF_OPEN') { + this.successes = 0; + this.halfOpenAttempts = 0; + } + + if (this.onStateChange) { + this.onStateChange(this.name, oldState, newState); + } + } + + getState(): CircuitBreakerState { + return this.state; + } + + getStats(): { + name: string; + state: CircuitBreakerState; + failures: number; + successes: number; + lastFailureTime: number; + } { + return { + name: this.name, + state: this.state, + failures: this.failures, + successes: this.successes, + lastFailureTime: this.lastFailureTime, + }; + } + + reset(): void { + this.transitionTo('CLOSED'); + } +} + +// Singleton registry para circuit breakers +class CircuitBreakerRegistry { + private breakers: Map = new Map(); + + get(name: string, options?: CircuitBreakerOptions): CircuitBreaker { + let breaker = this.breakers.get(name); + if (!breaker) { + breaker = new CircuitBreaker(name, options); + this.breakers.set(name, breaker); + } + return breaker; + } + + getAll(): Map { + return this.breakers; + } + + getAllStats(): Array> { + return Array.from(this.breakers.values()).map((b) => b.getStats()); + } +} + +export const circuitBreakerRegistry = new CircuitBreakerRegistry(); diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts new file mode 100644 index 0000000..be02c10 --- /dev/null +++ b/src/shared/utils/index.ts @@ -0,0 +1,7 @@ +export { + CircuitBreaker, + CircuitBreakerOpenError, + CircuitBreakerState, + CircuitBreakerOptions, + circuitBreakerRegistry, +} from './circuit-breaker'; 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"] +}