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