Migración desde erp-core/backend - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bd9d95c288
commit
3ce5c6ad17
22
.env.example
Normal file
22
.env.example
Normal file
@ -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
|
||||||
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@ -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/
|
||||||
52
Dockerfile
Normal file
52
Dockerfile
Normal file
@ -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"]
|
||||||
78
TYPEORM_DEPENDENCIES.md
Normal file
78
TYPEORM_DEPENDENCIES.md
Normal file
@ -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
|
||||||
302
TYPEORM_INTEGRATION_SUMMARY.md
Normal file
302
TYPEORM_INTEGRATION_SUMMARY.md
Normal file
@ -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
|
||||||
536
TYPEORM_USAGE_EXAMPLES.md
Normal file
536
TYPEORM_USAGE_EXAMPLES.md
Normal file
@ -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<User>;
|
||||||
|
private roleRepository: Repository<Role>;
|
||||||
|
|
||||||
|
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<User> {
|
||||||
|
const user = this.userRepository.create(data);
|
||||||
|
return await this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar usuario por email (con roles)
|
||||||
|
async findByEmail(email: string): Promise<User | null> {
|
||||||
|
return await this.userRepository.findOne({
|
||||||
|
where: { email },
|
||||||
|
relations: ['roles'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar usuario por ID
|
||||||
|
async findById(id: string): Promise<User | null> {
|
||||||
|
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<User>): Promise<User | null> {
|
||||||
|
await this.userRepository.update(id, data);
|
||||||
|
return await this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asignar rol a usuario
|
||||||
|
async assignRole(userId: string, roleId: string): Promise<User | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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<User[]> {
|
||||||
|
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<User> {
|
||||||
|
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<User | null> {
|
||||||
|
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<User | null> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<User> {
|
||||||
|
constructor() {
|
||||||
|
super(User, AppDataSource.createEntityManager());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Método personalizado
|
||||||
|
async findActiveUsers(): Promise<User[]> {
|
||||||
|
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<User[]> {
|
||||||
|
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
|
||||||
8585
package-lock.json
generated
Normal file
8585
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
package.json
Normal file
59
package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
134
service.descriptor.yml
Normal file
134
service.descriptor.yml
Normal file
@ -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"
|
||||||
503
src/app.integration.ts
Normal file
503
src/app.integration.ts
Normal file
@ -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<Express> {
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
112
src/app.ts
Normal file
112
src/app.ts
Normal file
@ -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;
|
||||||
69
src/config/database.ts
Normal file
69
src/config/database.ts
Normal file
@ -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<boolean> {
|
||||||
|
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<T = any>(text: string, params?: any[]): Promise<T[]> {
|
||||||
|
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<T = any>(text: string, params?: any[]): Promise<T | null> {
|
||||||
|
const rows = await query<T>(text, params);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getClient() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closePool(): Promise<void> {
|
||||||
|
await pool.end();
|
||||||
|
logger.info('Database pool closed');
|
||||||
|
}
|
||||||
35
src/config/index.ts
Normal file
35
src/config/index.ts
Normal file
@ -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;
|
||||||
178
src/config/redis.ts
Normal file
178
src/config/redis.ts
Normal file
@ -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<boolean> - true si la conexión fue exitosa
|
||||||
|
*/
|
||||||
|
export async function initializeRedis(): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> - true si el token está en blacklist
|
||||||
|
*/
|
||||||
|
export async function isTokenBlacklisted(token: string): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/config/swagger.config.ts
Normal file
200
src/config/swagger.config.ts
Normal file
@ -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 <token>
|
||||||
|
|
||||||
|
## 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 };
|
||||||
215
src/config/typeorm.ts
Normal file
215
src/config/typeorm.ts
Normal file
@ -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<boolean> - true si la conexión fue exitosa
|
||||||
|
*/
|
||||||
|
export async function initializeTypeORM(): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
138
src/docs/openapi.yaml
Normal file
138
src/docs/openapi.yaml
Normal file
@ -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
|
||||||
71
src/index.ts
Normal file
71
src/index.ts
Normal file
@ -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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
66
src/modules/ai/ai.module.ts
Normal file
66
src/modules/ai/ai.module.ts
Normal file
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
381
src/modules/ai/controllers/ai.controller.ts
Normal file
381
src/modules/ai/controllers/ai.controller.ts
Normal file
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/modules/ai/controllers/index.ts
Normal file
1
src/modules/ai/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AIController } from './ai.controller';
|
||||||
343
src/modules/ai/dto/ai.dto.ts
Normal file
343
src/modules/ai/dto/ai.dto.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
allowedModels?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, any>;
|
||||||
|
|
||||||
|
@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<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 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[];
|
||||||
|
}
|
||||||
9
src/modules/ai/dto/index.ts
Normal file
9
src/modules/ai/dto/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export {
|
||||||
|
CreatePromptDto,
|
||||||
|
UpdatePromptDto,
|
||||||
|
CreateConversationDto,
|
||||||
|
UpdateConversationDto,
|
||||||
|
AddMessageDto,
|
||||||
|
LogUsageDto,
|
||||||
|
UpdateQuotaDto,
|
||||||
|
} from './ai.dto';
|
||||||
92
src/modules/ai/entities/completion.entity.ts
Normal file
92
src/modules/ai/entities/completion.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@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<string, any>;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
160
src/modules/ai/entities/conversation.entity.ts
Normal file
160
src/modules/ai/entities/conversation.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@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<string, any>;
|
||||||
|
|
||||||
|
@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<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'function_result', type: 'jsonb', nullable: true })
|
||||||
|
functionResult: Record<string, any>;
|
||||||
|
|
||||||
|
@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<string, any>;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
77
src/modules/ai/entities/embedding.entity.ts
Normal file
77
src/modules/ai/entities/embedding.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
7
src/modules/ai/entities/index.ts
Normal file
7
src/modules/ai/entities/index.ts
Normal file
@ -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';
|
||||||
98
src/modules/ai/entities/knowledge-base.entity.ts
Normal file
98
src/modules/ai/entities/knowledge-base.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
78
src/modules/ai/entities/model.entity.ts
Normal file
78
src/modules/ai/entities/model.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
110
src/modules/ai/entities/prompt.entity.ts
Normal file
110
src/modules/ai/entities/prompt.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'functions', type: 'jsonb', default: [] })
|
||||||
|
functions: Record<string, any>[];
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
120
src/modules/ai/entities/usage.entity.ts
Normal file
120
src/modules/ai/entities/usage.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
5
src/modules/ai/index.ts
Normal file
5
src/modules/ai/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { AIModule, AIModuleOptions } from './ai.module';
|
||||||
|
export * from './entities';
|
||||||
|
export * from './services';
|
||||||
|
export * from './controllers';
|
||||||
|
export * from './dto';
|
||||||
384
src/modules/ai/services/ai.service.ts
Normal file
384
src/modules/ai/services/ai.service.ts
Normal file
@ -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<AIModel>,
|
||||||
|
private readonly conversationRepository: Repository<AIConversation>,
|
||||||
|
private readonly messageRepository: Repository<AIMessage>,
|
||||||
|
private readonly promptRepository: Repository<AIPrompt>,
|
||||||
|
private readonly usageLogRepository: Repository<AIUsageLog>,
|
||||||
|
private readonly quotaRepository: Repository<AITenantQuota>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MODELS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async findAllModels(): Promise<AIModel[]> {
|
||||||
|
return this.modelRepository.find({
|
||||||
|
where: { isActive: true },
|
||||||
|
order: { provider: 'ASC', name: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findModel(id: string): Promise<AIModel | null> {
|
||||||
|
return this.modelRepository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findModelByCode(code: string): Promise<AIModel | null> {
|
||||||
|
return this.modelRepository.findOne({ where: { code } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findModelsByProvider(provider: string): Promise<AIModel[]> {
|
||||||
|
return this.modelRepository.find({
|
||||||
|
where: { provider: provider as any, isActive: true },
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findModelsByType(modelType: string): Promise<AIModel[]> {
|
||||||
|
return this.modelRepository.find({
|
||||||
|
where: { modelType: modelType as any, isActive: true },
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// PROMPTS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async findAllPrompts(tenantId?: string): Promise<AIPrompt[]> {
|
||||||
|
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<AIPrompt | null> {
|
||||||
|
return this.promptRepository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPromptByCode(code: string, tenantId?: string): Promise<AIPrompt | null> {
|
||||||
|
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<AIPrompt>,
|
||||||
|
createdBy?: string
|
||||||
|
): Promise<AIPrompt> {
|
||||||
|
const prompt = this.promptRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
createdBy,
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
return this.promptRepository.save(prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePrompt(
|
||||||
|
id: string,
|
||||||
|
data: Partial<AIPrompt>,
|
||||||
|
updatedBy?: string
|
||||||
|
): Promise<AIPrompt | null> {
|
||||||
|
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<void> {
|
||||||
|
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<AIConversation[]> {
|
||||||
|
const where: FindOptionsWhere<AIConversation> = { 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<AIConversation | null> {
|
||||||
|
return this.conversationRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['messages'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUserConversations(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
limit: number = 20
|
||||||
|
): Promise<AIConversation[]> {
|
||||||
|
return this.conversationRepository.find({
|
||||||
|
where: { tenantId, userId },
|
||||||
|
order: { updatedAt: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createConversation(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
data: Partial<AIConversation>
|
||||||
|
): Promise<AIConversation> {
|
||||||
|
const conversation = this.conversationRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
return this.conversationRepository.save(conversation);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateConversation(
|
||||||
|
id: string,
|
||||||
|
data: Partial<AIConversation>
|
||||||
|
): Promise<AIConversation | null> {
|
||||||
|
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<boolean> {
|
||||||
|
const result = await this.conversationRepository.update(id, { status: 'archived' });
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MESSAGES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async findMessages(conversationId: string): Promise<AIMessage[]> {
|
||||||
|
return this.messageRepository.find({
|
||||||
|
where: { conversationId },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMessage(conversationId: string, data: Partial<AIMessage>): Promise<AIMessage> {
|
||||||
|
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<number> {
|
||||||
|
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<AIUsageLog>): Promise<AIUsageLog> {
|
||||||
|
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<string, { requests: number; tokens: number; cost: number }>;
|
||||||
|
}> {
|
||||||
|
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<string, { requests: number; tokens: number; cost: number }> = {};
|
||||||
|
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<AITenantQuota | null> {
|
||||||
|
return this.quotaRepository.findOne({ where: { tenantId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTenantQuota(
|
||||||
|
tenantId: string,
|
||||||
|
data: Partial<AITenantQuota>
|
||||||
|
): Promise<AITenantQuota> {
|
||||||
|
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<void> {
|
||||||
|
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<number> {
|
||||||
|
const result = await this.quotaRepository.update(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
currentRequestsMonth: 0,
|
||||||
|
currentTokensMonth: 0,
|
||||||
|
currentSpendMonth: 0,
|
||||||
|
lastResetAt: new Date(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return result.affected ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/modules/ai/services/index.ts
Normal file
1
src/modules/ai/services/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AIService, ConversationFilters } from './ai.service';
|
||||||
70
src/modules/audit/audit.module.ts
Normal file
70
src/modules/audit/audit.module.ts
Normal file
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
342
src/modules/audit/controllers/audit.controller.ts
Normal file
342
src/modules/audit/controllers/audit.controller.ts
Normal file
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/modules/audit/controllers/index.ts
Normal file
1
src/modules/audit/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AuditController } from './audit.controller';
|
||||||
346
src/modules/audit/dto/audit.dto.ts
Normal file
346
src/modules/audit/dto/audit.dto.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
newValues?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
|
||||||
|
@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<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
newData?: Record<string, any>;
|
||||||
|
|
||||||
|
@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<string, any>;
|
||||||
|
|
||||||
|
@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<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
newValue?: Record<string, any>;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
changeReason?: string;
|
||||||
|
}
|
||||||
10
src/modules/audit/dto/index.ts
Normal file
10
src/modules/audit/dto/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export {
|
||||||
|
CreateAuditLogDto,
|
||||||
|
CreateEntityChangeDto,
|
||||||
|
CreateLoginHistoryDto,
|
||||||
|
CreateSensitiveDataAccessDto,
|
||||||
|
CreateDataExportDto,
|
||||||
|
UpdateDataExportStatusDto,
|
||||||
|
CreatePermissionChangeDto,
|
||||||
|
CreateConfigChangeDto,
|
||||||
|
} from './audit.dto';
|
||||||
108
src/modules/audit/entities/audit-log.entity.ts
Normal file
108
src/modules/audit/entities/audit-log.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'new_values', type: 'jsonb', nullable: true })
|
||||||
|
newValues: Record<string, any>;
|
||||||
|
|
||||||
|
@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<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'location', type: 'jsonb', default: {} })
|
||||||
|
location: Record<string, any>;
|
||||||
|
|
||||||
|
@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<string, any>;
|
||||||
|
|
||||||
|
@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<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'tags', type: 'text', array: true, default: [] })
|
||||||
|
tags: string[];
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
47
src/modules/audit/entities/config-change.entity.ts
Normal file
47
src/modules/audit/entities/config-change.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'new_value', type: 'jsonb', nullable: true })
|
||||||
|
newValue: Record<string, any>;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
80
src/modules/audit/entities/data-export.entity.ts
Normal file
80
src/modules/audit/entities/data-export.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
55
src/modules/audit/entities/entity-change.entity.ts
Normal file
55
src/modules/audit/entities/entity-change.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@Column({ name: 'changes', type: 'jsonb', default: [] })
|
||||||
|
changes: Record<string, any>[];
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
7
src/modules/audit/entities/index.ts
Normal file
7
src/modules/audit/entities/index.ts
Normal file
@ -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';
|
||||||
106
src/modules/audit/entities/login-history.entity.ts
Normal file
106
src/modules/audit/entities/login-history.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
63
src/modules/audit/entities/permission-change.entity.ts
Normal file
63
src/modules/audit/entities/permission-change.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
62
src/modules/audit/entities/sensitive-data-access.entity.ts
Normal file
62
src/modules/audit/entities/sensitive-data-access.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
5
src/modules/audit/index.ts
Normal file
5
src/modules/audit/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { AuditModule, AuditModuleOptions } from './audit.module';
|
||||||
|
export * from './entities';
|
||||||
|
export * from './services';
|
||||||
|
export * from './controllers';
|
||||||
|
export * from './dto';
|
||||||
303
src/modules/audit/services/audit.service.ts
Normal file
303
src/modules/audit/services/audit.service.ts
Normal file
@ -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<AuditLog>,
|
||||||
|
private readonly entityChangeRepository: Repository<EntityChange>,
|
||||||
|
private readonly loginHistoryRepository: Repository<LoginHistory>,
|
||||||
|
private readonly sensitiveDataAccessRepository: Repository<SensitiveDataAccess>,
|
||||||
|
private readonly dataExportRepository: Repository<DataExport>,
|
||||||
|
private readonly permissionChangeRepository: Repository<PermissionChange>,
|
||||||
|
private readonly configChangeRepository: Repository<ConfigChange>
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// AUDIT LOGS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async createAuditLog(tenantId: string, data: Partial<AuditLog>): Promise<AuditLog> {
|
||||||
|
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<AuditLog> = { 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<AuditLog[]> {
|
||||||
|
return this.auditLogRepository.find({
|
||||||
|
where: { tenantId, entityType, entityId },
|
||||||
|
order: { createdAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ENTITY CHANGES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async createEntityChange(tenantId: string, data: Partial<EntityChange>): Promise<EntityChange> {
|
||||||
|
const change = this.entityChangeRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
return this.entityChangeRepository.save(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findEntityChanges(
|
||||||
|
tenantId: string,
|
||||||
|
entityType: string,
|
||||||
|
entityId: string
|
||||||
|
): Promise<EntityChange[]> {
|
||||||
|
return this.entityChangeRepository.find({
|
||||||
|
where: { tenantId, entityType, entityId },
|
||||||
|
order: { changedAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEntityVersion(
|
||||||
|
tenantId: string,
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
version: number
|
||||||
|
): Promise<EntityChange | null> {
|
||||||
|
return this.entityChangeRepository.findOne({
|
||||||
|
where: { tenantId, entityType, entityId, version },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// LOGIN HISTORY
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async createLoginHistory(data: Partial<LoginHistory>): Promise<LoginHistory> {
|
||||||
|
const login = this.loginHistoryRepository.create(data);
|
||||||
|
return this.loginHistoryRepository.save(login);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findLoginHistory(
|
||||||
|
userId: string,
|
||||||
|
tenantId?: string,
|
||||||
|
limit: number = 20
|
||||||
|
): Promise<LoginHistory[]> {
|
||||||
|
const where: FindOptionsWhere<LoginHistory> = { userId };
|
||||||
|
if (tenantId) where.tenantId = tenantId;
|
||||||
|
|
||||||
|
return this.loginHistoryRepository.find({
|
||||||
|
where,
|
||||||
|
order: { loginAt: 'DESC' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveSessionsCount(userId: string): Promise<number> {
|
||||||
|
return this.loginHistoryRepository.count({
|
||||||
|
where: { userId, logoutAt: undefined, status: 'success' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async markSessionLogout(sessionId: string): Promise<boolean> {
|
||||||
|
const result = await this.loginHistoryRepository.update(
|
||||||
|
{ sessionId },
|
||||||
|
{ logoutAt: new Date() }
|
||||||
|
);
|
||||||
|
return (result.affected ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// SENSITIVE DATA ACCESS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async logSensitiveDataAccess(
|
||||||
|
tenantId: string,
|
||||||
|
data: Partial<SensitiveDataAccess>
|
||||||
|
): Promise<SensitiveDataAccess> {
|
||||||
|
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<SensitiveDataAccess[]> {
|
||||||
|
const where: FindOptionsWhere<SensitiveDataAccess> = { 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<DataExport>): Promise<DataExport> {
|
||||||
|
const exportRecord = this.dataExportRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
return this.dataExportRepository.save(exportRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findDataExport(id: string): Promise<DataExport | null> {
|
||||||
|
return this.dataExportRepository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUserDataExports(tenantId: string, userId: string): Promise<DataExport[]> {
|
||||||
|
return this.dataExportRepository.find({
|
||||||
|
where: { tenantId, requestedBy: userId },
|
||||||
|
order: { requestedAt: 'DESC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDataExportStatus(
|
||||||
|
id: string,
|
||||||
|
status: string,
|
||||||
|
updates: Partial<DataExport> = {}
|
||||||
|
): Promise<DataExport | null> {
|
||||||
|
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<PermissionChange>
|
||||||
|
): Promise<PermissionChange> {
|
||||||
|
const change = this.permissionChangeRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
return this.permissionChangeRepository.save(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPermissionChanges(
|
||||||
|
tenantId: string,
|
||||||
|
targetUserId?: string
|
||||||
|
): Promise<PermissionChange[]> {
|
||||||
|
const where: FindOptionsWhere<PermissionChange> = { tenantId };
|
||||||
|
if (targetUserId) where.targetUserId = targetUserId;
|
||||||
|
|
||||||
|
return this.permissionChangeRepository.find({
|
||||||
|
where,
|
||||||
|
order: { changedAt: 'DESC' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// CONFIG CHANGES
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
async logConfigChange(tenantId: string, data: Partial<ConfigChange>): Promise<ConfigChange> {
|
||||||
|
const change = this.configChangeRepository.create({
|
||||||
|
...data,
|
||||||
|
tenantId,
|
||||||
|
});
|
||||||
|
return this.configChangeRepository.save(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findConfigChanges(tenantId: string, configType?: string): Promise<ConfigChange[]> {
|
||||||
|
const where: FindOptionsWhere<ConfigChange> = { 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<ConfigChange | null> {
|
||||||
|
return this.configChangeRepository.findOne({
|
||||||
|
where: { tenantId, configKey, version },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/modules/audit/services/index.ts
Normal file
1
src/modules/audit/services/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { AuditService, AuditLogFilters, PaginationOptions } from './audit.service';
|
||||||
331
src/modules/auth/apiKeys.controller.ts
Normal file
331
src/modules/auth/apiKeys.controller.ts
Normal file
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
56
src/modules/auth/apiKeys.routes.ts
Normal file
56
src/modules/auth/apiKeys.routes.ts
Normal file
@ -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;
|
||||||
491
src/modules/auth/apiKeys.service.ts
Normal file
491
src/modules/auth/apiKeys.service.ts
Normal file
@ -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<ApiKey, 'key_hash'>;
|
||||||
|
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<string> {
|
||||||
|
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<boolean> {
|
||||||
|
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<ApiKeyWithPlainKey> {
|
||||||
|
// 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<ApiKey>(
|
||||||
|
`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<Omit<ApiKey, 'key_hash'>[]> {
|
||||||
|
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<ApiKey>(
|
||||||
|
`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<Omit<ApiKey, 'key_hash'> | null> {
|
||||||
|
const apiKey = await queryOne<ApiKey>(
|
||||||
|
`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<Omit<ApiKey, 'key_hash'>> {
|
||||||
|
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<ApiKey>(
|
||||||
|
`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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<ApiKeyValidationResult> {
|
||||||
|
// 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<ApiKey>(
|
||||||
|
`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<ApiKeyWithPlainKey> {
|
||||||
|
const existing = await queryOne<ApiKey>(
|
||||||
|
'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<ApiKey>(
|
||||||
|
`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();
|
||||||
192
src/modules/auth/auth.controller.ts
Normal file
192
src/modules/auth/auth.controller.ts
Normal file
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
18
src/modules/auth/auth.routes.ts
Normal file
18
src/modules/auth/auth.routes.ts
Normal file
@ -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;
|
||||||
234
src/modules/auth/auth.service.ts
Normal file
234
src/modules/auth/auth.service.ts
Normal file
@ -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<User, 'passwordHash'> & { firstName: string; lastName: string };
|
||||||
|
tokens: TokenPair;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthService {
|
||||||
|
private userRepository: Repository<User>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.userRepository = AppDataSource.getRepository(User);
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(dto: LoginDto): Promise<LoginResponse> {
|
||||||
|
// 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<LoginResponse> {
|
||||||
|
// 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<TokenPair> {
|
||||||
|
// Delegate completely to TokenService
|
||||||
|
return tokenService.refreshTokens(refreshToken, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(sessionId: string): Promise<void> {
|
||||||
|
await tokenService.revokeSession(sessionId, 'user_logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
async logoutAll(userId: string): Promise<number> {
|
||||||
|
return tokenService.revokeAllUserSessions(userId, 'logout_all');
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<void> {
|
||||||
|
// 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<Omit<User, 'passwordHash'>> {
|
||||||
|
// 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();
|
||||||
87
src/modules/auth/entities/api-key.entity.ts
Normal file
87
src/modules/auth/entities/api-key.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
93
src/modules/auth/entities/company.entity.ts
Normal file
93
src/modules/auth/entities/company.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
89
src/modules/auth/entities/group.entity.ts
Normal file
89
src/modules/auth/entities/group.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
15
src/modules/auth/entities/index.ts
Normal file
15
src/modules/auth/entities/index.ts
Normal file
@ -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';
|
||||||
87
src/modules/auth/entities/mfa-audit-log.entity.ts
Normal file
87
src/modules/auth/entities/mfa-audit-log.entity.ts
Normal file
@ -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<string, any> | null;
|
||||||
|
|
||||||
|
// Metadata adicional
|
||||||
|
@Column({ type: 'jsonb', default: {}, nullable: true })
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => User)
|
||||||
|
@JoinColumn({ name: 'user_id' })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
191
src/modules/auth/entities/oauth-provider.entity.ts
Normal file
191
src/modules/auth/entities/oauth-provider.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
66
src/modules/auth/entities/oauth-state.entity.ts
Normal file
66
src/modules/auth/entities/oauth-state.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
73
src/modules/auth/entities/oauth-user-link.entity.ts
Normal file
73
src/modules/auth/entities/oauth-user-link.entity.ts
Normal file
@ -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<string, any> | 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;
|
||||||
|
}
|
||||||
45
src/modules/auth/entities/password-reset.entity.ts
Normal file
45
src/modules/auth/entities/password-reset.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
52
src/modules/auth/entities/permission.entity.ts
Normal file
52
src/modules/auth/entities/permission.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
84
src/modules/auth/entities/role.entity.ts
Normal file
84
src/modules/auth/entities/role.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
90
src/modules/auth/entities/session.entity.ts
Normal file
90
src/modules/auth/entities/session.entity.ts
Normal file
@ -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<string, any> | 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;
|
||||||
|
}
|
||||||
93
src/modules/auth/entities/tenant.entity.ts
Normal file
93
src/modules/auth/entities/tenant.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
115
src/modules/auth/entities/trusted-device.entity.ts
Normal file
115
src/modules/auth/entities/trusted-device.entity.ts
Normal file
@ -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<string, any> | 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;
|
||||||
|
}
|
||||||
141
src/modules/auth/entities/user.entity.ts
Normal file
141
src/modules/auth/entities/user.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
90
src/modules/auth/entities/verification-code.entity.ts
Normal file
90
src/modules/auth/entities/verification-code.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
8
src/modules/auth/index.ts
Normal file
8
src/modules/auth/index.ts
Normal file
@ -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';
|
||||||
456
src/modules/auth/services/token.service.ts
Normal file
456
src/modules/auth/services/token.service.ts
Normal file
@ -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<Session>;
|
||||||
|
|
||||||
|
// 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<TokenPair> - Access and refresh tokens with expiration dates
|
||||||
|
*/
|
||||||
|
async generateTokenPair(user: User, metadata: RequestMetadata): Promise<TokenPair> {
|
||||||
|
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<TokenPair> - New access and refresh tokens
|
||||||
|
* @throws UnauthorizedError if token is invalid or replay detected
|
||||||
|
*/
|
||||||
|
async refreshTokens(refreshToken: string, metadata: RequestMetadata): Promise<TokenPair> {
|
||||||
|
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<void> {
|
||||||
|
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> - Number of sessions revoked
|
||||||
|
*/
|
||||||
|
async revokeAllUserSessions(userId: string, reason: string): Promise<number> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> - true if blacklisted
|
||||||
|
*/
|
||||||
|
async isAccessTokenBlacklisted(jti: string): Promise<boolean> {
|
||||||
|
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<JwtPayload, 'iat' | 'exp'>, 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();
|
||||||
60
src/modules/billing-usage/billing-usage.module.ts
Normal file
60
src/modules/billing-usage/billing-usage.module.ts
Normal file
@ -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;
|
||||||
8
src/modules/billing-usage/controllers/index.ts
Normal file
8
src/modules/billing-usage/controllers/index.ts
Normal file
@ -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';
|
||||||
258
src/modules/billing-usage/controllers/invoices.controller.ts
Normal file
258
src/modules/billing-usage/controllers/invoices.controller.ts
Normal file
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const count = await this.service.markOverdueInvoices();
|
||||||
|
res.json({ data: { markedOverdue: count } });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
const comparison = await this.service.comparePlans(req.params.id, req.params.otherId);
|
||||||
|
res.json({ data: comparison });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
src/modules/billing-usage/controllers/usage.controller.ts
Normal file
173
src/modules/billing-usage/controllers/usage.controller.ts
Normal file
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/modules/billing-usage/dto/create-invoice.dto.ts
Normal file
75
src/modules/billing-usage/dto/create-invoice.dto.ts
Normal file
@ -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<string, any>;
|
||||||
|
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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateInvoiceDto {
|
||||||
|
billingName?: string;
|
||||||
|
billingEmail?: string;
|
||||||
|
billingAddress?: Record<string, any>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -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<string, boolean>;
|
||||||
|
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<string, boolean>;
|
||||||
|
isActive?: boolean;
|
||||||
|
isPublic?: boolean;
|
||||||
|
}
|
||||||
57
src/modules/billing-usage/dto/create-subscription.dto.ts
Normal file
57
src/modules/billing-usage/dto/create-subscription.dto.ts
Normal file
@ -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<string, any>;
|
||||||
|
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<string, any>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
8
src/modules/billing-usage/dto/index.ts
Normal file
8
src/modules/billing-usage/dto/index.ts
Normal file
@ -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';
|
||||||
90
src/modules/billing-usage/dto/usage-tracking.dto.ts
Normal file
90
src/modules/billing-usage/dto/usage-tracking.dto.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Usage Tracking DTO
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class RecordUsageDto {
|
||||||
|
tenantId: string;
|
||||||
|
periodStart: Date;
|
||||||
|
periodEnd: Date;
|
||||||
|
activeUsers?: number;
|
||||||
|
peakConcurrentUsers?: number;
|
||||||
|
usersByProfile?: Record<string, number>;
|
||||||
|
usersByPlatform?: Record<string, number>;
|
||||||
|
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<string, number>;
|
||||||
|
usersByPlatform?: Record<string, number>;
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
72
src/modules/billing-usage/entities/billing-alert.entity.ts
Normal file
72
src/modules/billing-usage/entities/billing-alert.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
8
src/modules/billing-usage/entities/index.ts
Normal file
8
src/modules/billing-usage/entities/index.ts
Normal file
@ -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';
|
||||||
65
src/modules/billing-usage/entities/invoice-item.entity.ts
Normal file
65
src/modules/billing-usage/entities/invoice-item.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
// Relaciones
|
||||||
|
@ManyToOne(() => Invoice, (invoice) => invoice.items, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'invoice_id' })
|
||||||
|
invoice: Invoice;
|
||||||
|
}
|
||||||
121
src/modules/billing-usage/entities/invoice.entity.ts
Normal file
121
src/modules/billing-usage/entities/invoice.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@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[];
|
||||||
|
}
|
||||||
85
src/modules/billing-usage/entities/payment-method.entity.ts
Normal file
85
src/modules/billing-usage/entities/payment-method.entity.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -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<string, boolean>;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
117
src/modules/billing-usage/entities/tenant-subscription.entity.ts
Normal file
117
src/modules/billing-usage/entities/tenant-subscription.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
73
src/modules/billing-usage/entities/usage-event.entity.ts
Normal file
73
src/modules/billing-usage/entities/usage-event.entity.ts
Normal file
@ -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<string, any>;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
91
src/modules/billing-usage/entities/usage-tracking.entity.ts
Normal file
91
src/modules/billing-usage/entities/usage-tracking.entity.ts
Normal file
@ -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<string, number>; // {"ADM": 2, "VNT": 5, "ALM": 3}
|
||||||
|
|
||||||
|
// Por plataforma
|
||||||
|
@Column({ name: 'users_by_platform', type: 'jsonb', default: {} })
|
||||||
|
usersByPlatform: Record<string, number>; // {"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;
|
||||||
|
}
|
||||||
18
src/modules/billing-usage/index.ts
Normal file
18
src/modules/billing-usage/index.ts
Normal file
@ -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';
|
||||||
8
src/modules/billing-usage/services/index.ts
Normal file
8
src/modules/billing-usage/services/index.ts
Normal file
@ -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';
|
||||||
471
src/modules/billing-usage/services/invoices.service.ts
Normal file
471
src/modules/billing-usage/services/invoices.service.ts
Normal file
@ -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<Invoice>;
|
||||||
|
private itemRepository: Repository<InvoiceItem>;
|
||||||
|
private subscriptionRepository: Repository<TenantSubscription>;
|
||||||
|
private usageRepository: Repository<UsageTracking>;
|
||||||
|
|
||||||
|
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<Invoice> {
|
||||||
|
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<Invoice>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate invoice automatically from subscription
|
||||||
|
*/
|
||||||
|
async generateFromSubscription(dto: GenerateInvoiceDto): Promise<Invoice> {
|
||||||
|
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<Invoice | null> {
|
||||||
|
return this.invoiceRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['items'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find invoice by number
|
||||||
|
*/
|
||||||
|
async findByNumber(invoiceNumber: string): Promise<Invoice | null> {
|
||||||
|
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<Invoice> {
|
||||||
|
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<Invoice> {
|
||||||
|
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<Invoice> {
|
||||||
|
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<Invoice> {
|
||||||
|
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<Invoice> {
|
||||||
|
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<number> {
|
||||||
|
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<InvoiceStatus, number>;
|
||||||
|
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<InvoiceStatus, number> = {
|
||||||
|
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<string> {
|
||||||
|
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')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
200
src/modules/billing-usage/services/subscription-plans.service.ts
Normal file
200
src/modules/billing-usage/services/subscription-plans.service.ts
Normal file
@ -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<SubscriptionPlan>;
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {
|
||||||
|
this.planRepository = dataSource.getRepository(SubscriptionPlan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new subscription plan
|
||||||
|
*/
|
||||||
|
async create(dto: CreateSubscriptionPlanDto): Promise<SubscriptionPlan> {
|
||||||
|
// 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<SubscriptionPlan[]> {
|
||||||
|
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<SubscriptionPlan[]> {
|
||||||
|
return this.findAll({ isActive: true, isPublic: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find plan by ID
|
||||||
|
*/
|
||||||
|
async findById(id: string): Promise<SubscriptionPlan | null> {
|
||||||
|
return this.planRepository.findOne({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find plan by code
|
||||||
|
*/
|
||||||
|
async findByCode(code: string): Promise<SubscriptionPlan | null> {
|
||||||
|
return this.planRepository.findOne({ where: { code } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a plan
|
||||||
|
*/
|
||||||
|
async update(id: string, dto: UpdateSubscriptionPlanDto): Promise<SubscriptionPlan> {
|
||||||
|
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<void> {
|
||||||
|
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<SubscriptionPlan> {
|
||||||
|
return this.update(id, { isActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two plans
|
||||||
|
*/
|
||||||
|
async comparePlans(
|
||||||
|
planId1: string,
|
||||||
|
planId2: string
|
||||||
|
): Promise<{
|
||||||
|
plan1: SubscriptionPlan;
|
||||||
|
plan2: SubscriptionPlan;
|
||||||
|
differences: Record<string, { plan1: any; plan2: any }>;
|
||||||
|
}> {
|
||||||
|
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<string, { plan1: any; plan2: any }> = {};
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
384
src/modules/billing-usage/services/subscriptions.service.ts
Normal file
384
src/modules/billing-usage/services/subscriptions.service.ts
Normal file
@ -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<TenantSubscription>;
|
||||||
|
private planRepository: Repository<SubscriptionPlan>;
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {
|
||||||
|
this.subscriptionRepository = dataSource.getRepository(TenantSubscription);
|
||||||
|
this.planRepository = dataSource.getRepository(SubscriptionPlan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new subscription
|
||||||
|
*/
|
||||||
|
async create(dto: CreateTenantSubscriptionDto): Promise<TenantSubscription> {
|
||||||
|
// 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<TenantSubscription | null> {
|
||||||
|
return this.subscriptionRepository.findOne({
|
||||||
|
where: { tenantId },
|
||||||
|
relations: ['plan'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find subscription by ID
|
||||||
|
*/
|
||||||
|
async findById(id: string): Promise<TenantSubscription | null> {
|
||||||
|
return this.subscriptionRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['plan'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update subscription
|
||||||
|
*/
|
||||||
|
async update(id: string, dto: UpdateTenantSubscriptionDto): Promise<TenantSubscription> {
|
||||||
|
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<TenantSubscription> {
|
||||||
|
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<TenantSubscription> {
|
||||||
|
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<TenantSubscription> {
|
||||||
|
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<TenantSubscription> {
|
||||||
|
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<TenantSubscription> {
|
||||||
|
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<TenantSubscription> {
|
||||||
|
return this.updateStatus(id, 'past_due');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspend subscription
|
||||||
|
*/
|
||||||
|
async suspend(id: string): Promise<TenantSubscription> {
|
||||||
|
return this.updateStatus(id, 'suspended');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate subscription (from suspended or past_due)
|
||||||
|
*/
|
||||||
|
async activate(id: string): Promise<TenantSubscription> {
|
||||||
|
return this.updateStatus(id, 'active');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update subscription status
|
||||||
|
*/
|
||||||
|
private async updateStatus(id: string, status: SubscriptionStatus): Promise<TenantSubscription> {
|
||||||
|
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<TenantSubscription[]> {
|
||||||
|
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<TenantSubscription[]> {
|
||||||
|
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<SubscriptionStatus, number>;
|
||||||
|
byPlan: Record<string, number>;
|
||||||
|
totalMRR: number;
|
||||||
|
totalARR: number;
|
||||||
|
}> {
|
||||||
|
const subscriptions = await this.subscriptionRepository.find({
|
||||||
|
relations: ['plan'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const byStatus: Record<SubscriptionStatus, number> = {
|
||||||
|
trial: 0,
|
||||||
|
active: 0,
|
||||||
|
past_due: 0,
|
||||||
|
cancelled: 0,
|
||||||
|
suspended: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const byPlan: Record<string, number> = {};
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
381
src/modules/billing-usage/services/usage-tracking.service.ts
Normal file
381
src/modules/billing-usage/services/usage-tracking.service.ts
Normal file
@ -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<UsageTracking>;
|
||||||
|
private subscriptionRepository: Repository<TenantSubscription>;
|
||||||
|
private planRepository: Repository<SubscriptionPlan>;
|
||||||
|
|
||||||
|
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<UsageTracking> {
|
||||||
|
// 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<UsageTracking> {
|
||||||
|
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<void> {
|
||||||
|
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<UsageTracking | null> {
|
||||||
|
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<UsageTracking[]> {
|
||||||
|
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<UsageSummaryDto> {
|
||||||
|
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<number> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user