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:
rckrdmrd 2026-01-16 08:10:37 -06:00
parent bd9d95c288
commit 3ce5c6ad17
482 changed files with 78029 additions and 0 deletions

22
.env.example Normal file
View 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
View 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
View 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
View 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

View 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
View 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

File diff suppressed because it is too large Load Diff

59
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
});
}
}

View 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
View 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
View 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
View 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);
});

View 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,
];
}
}

View 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);
}
}
}

View File

@ -0,0 +1 @@
export { AIController } from './ai.controller';

View 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[];
}

View File

@ -0,0 +1,9 @@
export {
CreatePromptDto,
UpdatePromptDto,
CreateConversationDto,
UpdateConversationDto,
AddMessageDto,
LogUsageDto,
UpdateQuotaDto,
} from './ai.dto';

View 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;
}

View 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;
}

View 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;
}

View 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';

View 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;
}

View 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;
}

View 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;
}

View 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
View File

@ -0,0 +1,5 @@
export { AIModule, AIModuleOptions } from './ai.module';
export * from './entities';
export * from './services';
export * from './controllers';
export * from './dto';

View 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;
}
}

View File

@ -0,0 +1 @@
export { AIService, ConversationFilters } from './ai.service';

View 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,
];
}
}

View 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);
}
}
}

View File

@ -0,0 +1 @@
export { AuditController } from './audit.controller';

View 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;
}

View File

@ -0,0 +1,10 @@
export {
CreateAuditLogDto,
CreateEntityChangeDto,
CreateLoginHistoryDto,
CreateSensitiveDataAccessDto,
CreateDataExportDto,
UpdateDataExportStatusDto,
CreatePermissionChangeDto,
CreateConfigChangeDto,
} from './audit.dto';

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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';

View 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;
}

View 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;
}

View 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;
}

View File

@ -0,0 +1,5 @@
export { AuditModule, AuditModuleOptions } from './audit.module';
export * from './entities';
export * from './services';
export * from './controllers';
export * from './dto';

View 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 },
});
}
}

View File

@ -0,0 +1 @@
export { AuditService, AuditLogFilters, PaginationOptions } from './audit.service';

View 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();

View 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;

View 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();

View 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();

View 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;

View 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();

View 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;
}

View 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;
}

View 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;
}

View 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';

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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';

View 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();

View 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;

View 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';

View 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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View 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);
}
}
}

View 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;
}

View File

@ -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;
}

View 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;
}

View 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';

View 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;
};
}

View 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;
}

View 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';

View 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;
}

View 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[];
}

View 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;
}

View File

@ -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;
}

View 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;
}

View 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;
}

View 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;
}

View 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';

View 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';

View 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')}`;
}
}

View 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 };
}
}

View 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,
};
}
}

View 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