diff --git a/README.md b/README.md
index 9bce1b3..99fd92b 100644
--- a/README.md
+++ b/README.md
@@ -1,302 +1,494 @@
-# Wise Auth - Microservicio de Autenticación
+# Wise Auth - Microservicio de Autenticación
-## 📋 Descripción
+## Descripción
-**Wise Auth** es un microservicio de autenticación y autorización construido con NestJS que proporciona autenticación OAuth 2.0 con Google y gestión de usuarios basada en roles (RBAC). Este servicio forma parte de la arquitectura de microservicios del proyecto ECIWISE.
+Wise Auth es el microservicio de autenticación y autorización del ecosistema ECIWISE. Está construido con NestJS y maneja todo el ciclo de autenticación OAuth 2.0 con Google, generación de tokens JWT, y gestión de usuarios con un sistema de roles basado en RBAC.
-### Características principales:
-- ✅ Autenticación OAuth 2.0 con Google
-- ✅ Gestión de tokens JWT
-- ✅ Sistema de roles (Estudiante, Tutor, Admin)
-- ✅ Guards globales para protección de rutas
-- ✅ Integración con PostgreSQL mediante Prisma ORM
-- ✅ Validación automática de datos con class-validator
-- ✅ Logging detallado de operaciones
+Este servicio actúa como el punto central de autenticación para los demás microservicios del proyecto, proporcionando tokens JWT que se validan en cada request. Además, incluye un módulo completo de gestión de usuarios con estadísticas, filtros avanzados y un sistema de caché optimizado.
+
+### Características principales
+
+- **Autenticación OAuth 2.0 con Google**: Flujo completo de autenticación con redirección al gateway
+- **Sistema JWT**: Tokens firmados con expiración configurable y validación automática
+- **RBAC (Role-Based Access Control)**: Sistema de roles con guards globales y decoradores personalizados
+- **Gestión de usuarios**: CRUD completo con filtros, paginación y estadísticas
+- **Sistema de caché inteligente**: Redis con fallback a memoria, invalidación automática
+- **Integración con Azure Service Bus**: Envío de notificaciones asíncronas
+- **Validación robusta**: class-validator + Joi para variables de entorno
+- **Documentación Swagger**: API completamente documentada e interactiva
---
-## 🛠️ Tecnologías
+## Stack Tecnológico
### Core
-- **[NestJS](https://nestjs.com/)** v11.0.1 - Framework backend progresivo para Node.js
-- **[TypeScript](https://www.typescriptlang.org/)** v5.7.3 - Superset tipado de JavaScript
-- **[Node.js](https://nodejs.org/)** - Entorno de ejecución
+- **NestJS** v11.0.1 - Framework principal
+- **TypeScript** v5.7.3
+- **Node.js** >= 18.x
### Base de Datos
-- **[PostgreSQL](https://www.postgresql.org/)** - Sistema de gestión de base de datos relacional
-- **[Prisma ORM](https://www.prisma.io/)** v6.19.0 - ORM de última generación para Node.js y TypeScript
-
-### Autenticación y Seguridad
-- **[Passport](https://www.passportjs.org/)** v0.7.0 - Middleware de autenticación
-- **[Passport-JWT](http://www.passportjs.org/packages/passport-jwt/)** v4.0.1 - Estrategia JWT para Passport
-- **[Passport-Google-OAuth20](http://www.passportjs.org/packages/passport-google-oauth20/)** v2.0.0 - Estrategia Google OAuth 2.0
-- **[@nestjs/jwt](https://docs.nestjs.com/security/authentication#jwt-functionality)** v11.0.1 - Módulo JWT para NestJS
-- **[bcrypt](https://github.com/kelektiv/node.bcrypt.js)** v6.0.0 - Librería de hashing
-
-### Validación
-- **[class-validator](https://github.com/typestack/class-validator)** v0.14.2 - Validación basada en decoradores
-- **[class-transformer](https://github.com/typestack/class-transformer)** v0.5.1 - Transformación de objetos
-- **[joi](https://joi.dev/)** v18.0.1 - Validación de esquemas para variables de entorno
-
-### Testing
-- **[Jest](https://jestjs.io/)** v30.0.0 - Framework de testing
-- **[Supertest](https://github.com/visionmedia/supertest)** v7.0.0 - Testing de APIs HTTP
-
-### Desarrollo
-- **[ESLint](https://eslint.org/)** v9.18.0 - Linter para código JavaScript/TypeScript
-- **[Prettier](https://prettier.io/)** v3.4.2 - Formateador de código
-- **[ts-node](https://typestrong.org/ts-node/)** v10.9.2 - Ejecución de TypeScript en Node.js
-
-### Documentación
-- **[@nestjs/swagger](https://docs.nestjs.com/openapi/introduction)** - Generación automática de documentación OpenAPI/Swagger
+- **PostgreSQL** >= 14.x
+- **Prisma ORM** v7.0.1 con adapter PostgreSQL
+- **@prisma/adapter-pg** v7.0.1
+
+### Autenticación
+- **Passport.js** v0.7.0
+- **passport-jwt** v4.0.1
+- **passport-google-oauth20** v2.0.0
+- **@nestjs/jwt** v11.0.1
+- **@nestjs/passport** v11.0.5
+
+### Infraestructura
+- **Redis** (opcional) - Sistema de caché distribuido
+- **Azure Service Bus** - Cola de mensajería para notificaciones
+- **cache-manager-redis-yet** v5.1.5
+
+### Validación y Transformación
+- **class-validator** v0.14.2
+- **class-transformer** v0.5.1
+- **joi** v18.0.1
+
+### Documentación y Testing
+- **@nestjs/swagger** v11.2.2
+- **Jest** v30.0.0
+- **Supertest** v7.0.0
---
-## 📦 Instalación
+## Instalación y Configuración
### Prerrequisitos
+
- Node.js >= 18.x
- npm >= 9.x
- PostgreSQL >= 14.x
- Cuenta de Google Cloud Platform (para OAuth)
+- Azure Service Bus (para notificaciones)
+- Redis (opcional, pero recomendado para producción)
+
+### 1. Clonar e instalar
-### 1. Clonar el repositorio
```bash
git clone https://github.com/DOSW2025/wise_auth.git
cd wise_auth
-```
-
-### 2. Instalar dependencias
-```bash
npm install
```
-### 3. Configurar variables de entorno
+### 2. Variables de entorno
-Crear un archivo `.env` en la raíz del proyecto con las siguientes variables:
+Crea un archivo `.env` en la raíz del proyecto:
```env
-# Puerto de la aplicación
+# Aplicación
PORT=3000
-# Base de datos PostgreSQL
+# Base de datos
DATABASE_URL="postgresql://usuario:password@localhost:5432/wise_auth?schema=public"
DIRECT_URL="postgresql://usuario:password@localhost:5432/wise_auth?schema=public"
-# JWT Configuration
-JWT_SECRET="tu_secreto_super_seguro_aqui_cambiar_en_produccion"
+# JWT
+JWT_SECRET="tu_secreto_super_seguro_cambiar_en_produccion"
JWT_EXPIRATION="7d"
# Google OAuth 2.0
GOOGLE_CLIENT_ID="tu-client-id.apps.googleusercontent.com"
GOOGLE_CLIENT_SECRET="tu-client-secret"
GOOGLE_CALLBACK_URL="http://localhost:3000/auth/google/callback"
+
+# Gateway
+GATEWAY_URL="http://localhost:4000"
+
+# Azure Service Bus
+SERVICEBUS_CONNECTION_STRING="Endpoint=sb://..."
+
+# Redis (opcional)
+REDIS_HOST="localhost"
+REDIS_PORT=6380
+REDIS_PASSWORD="tu_password_redis"
```
-> **Nota:** Puedes copiar el archivo `.env.example` como plantilla.
+**Importante**: En producción, usa variables de entorno seguras y nunca commitees el archivo `.env`.
-### 4. Configurar Google OAuth 2.0
+### 3. Configurar Google OAuth
-1. Ir a [Google Cloud Console](https://console.cloud.google.com/)
-2. Crear un nuevo proyecto o seleccionar uno existente
-3. Habilitar la API de Google+
-4. Crear credenciales OAuth 2.0:
- - **Tipo:** ID de cliente de OAuth 2.0
- - **Tipo de aplicación:** Aplicación web
- - **Orígenes autorizados:** `http://localhost:3000`
- - **URI de redirección autorizados:** `http://localhost:3000/auth/google/callback`
-5. Copiar el **Client ID** y **Client Secret** al archivo `.env`
+1. Ve a [Google Cloud Console](https://console.cloud.google.com/)
+2. Crea o selecciona un proyecto
+3. Habilita la API de Google+ (o Google Identity)
+4. Crea credenciales OAuth 2.0:
+ - Tipo: ID de cliente de OAuth 2.0
+ - Tipo de aplicación: Aplicación web
+ - Orígenes autorizados: `http://localhost:3000` (o tu dominio en producción)
+ - URI de redirección: `http://localhost:3000/auth/google/callback`
+5. Copia el Client ID y Client Secret al `.env`
-### 5. Configurar la base de datos
+### 4. Configurar base de datos
```bash
-# Crear la base de datos (si no existe)
+# Crear la base de datos
psql -U postgres -c "CREATE DATABASE wise_auth;"
-# Generar cliente de Prisma
+# El cliente de Prisma se genera automáticamente con npm install
+# pero si necesitas regenerarlo:
npx prisma generate
# Ejecutar migraciones
npx prisma migrate deploy
-# (Opcional) Visualizar la base de datos con Prisma Studio
+# Poblar datos iniciales (roles y estados)
+npx prisma db seed
+
+# (Opcional) Abrir Prisma Studio para ver los datos
npx prisma studio
```
+### 5. Configurar Redis (opcional pero recomendado)
+
+Si no configuras Redis, el sistema usará caché en memoria como fallback. Para producción, es altamente recomendado usar Redis.
+
+```bash
+# Con Docker
+docker run -d -p 6380:6379 --name redis-wise-auth redis:7-alpine
+
+# O instalar Redis localmente según tu sistema operativo
+```
+
---
-## 🚀 Ejecución
+## Ejecución
+
+### Desarrollo
-### Modo desarrollo (con hot-reload)
```bash
npm run start:dev
```
-El servidor estará disponible en `http://localhost:3000`
-### Modo producción
+El servidor inicia en `http://localhost:3000` con hot-reload activado. Los cambios en el código se reflejan automáticamente.
+
+### Producción
+
```bash
-# 1. Compilar el proyecto
+# Compilar
npm run build
-# 2. Ejecutar en producción
+# Ejecutar
npm run start:prod
```
-### Modo debug
+El script `start:prod` automáticamente:
+1. Genera el cliente de Prisma
+2. Ejecuta las migraciones pendientes
+3. Inicia la aplicación
+
+### Debug
+
```bash
npm run start:debug
```
-Permite conectar un debugger en el puerto 9229.
-### Otros comandos útiles
-```bash
-# Ejecutar sin compilar (producción)
-npm run start
+Permite conectar un debugger en el puerto 9229. Útil para debugging con VS Code o Chrome DevTools.
-# Formatear código
-npm run format
+---
+
+## Arquitectura
+
+### Estructura del proyecto
-# Verificar linting
-npm run lint
```
+src/
+├── auth/ # Módulo de autenticación
+│ ├── decorators/
+│ │ ├── get-user.decorator.ts # @GetUser() - Extrae usuario del request
+│ │ ├── public.decorator.ts # @Public() - Marca rutas públicas
+│ │ └── roles.decorator.ts # @Roles() - Define roles requeridos
+│ ├── dto/
+│ │ ├── auth-response.dto.ts # Respuesta de autenticación
+│ │ ├── google-user.dto.ts # DTO para datos de Google
+│ │ └── notificaciones.dto.ts # DTO para notificaciones
+│ ├── enums/
+│ │ └── role.enum.ts # Enums de roles y estados
+│ ├── guards/
+│ │ ├── google-auth.guard.ts # Guard para OAuth Google
+│ │ ├── jwt-auth.guard.ts # Guard JWT (global)
+│ │ └── roles.guard.ts # Guard de roles (global)
+│ ├── strategies/
+│ │ ├── google.strategy.ts # Estrategia Passport para Google
+│ │ └── jwt.strategy.ts # Estrategia Passport para JWT
+│ ├── auth.controller.ts # Endpoints de autenticación
+│ ├── auth.service.ts # Lógica de negocio de auth
+│ └── auth.module.ts # Módulo de autenticación
+├── gestion-usuarios/ # Módulo de gestión de usuarios
+│ ├── dto/ # DTOs para filtros y actualizaciones
+│ ├── gestion-usuarios.controller.ts
+│ ├── gestion-usuarios.service.ts
+│ └── gestion-usuarios.module.ts
+├── config/
+│ ├── envs.ts # Validación de variables de entorno
+│ └── index.ts
+├── prisma/
+│ ├── prisma.service.ts # Servicio Prisma con adapter
+│ └── prisma.module.ts
+├── app.module.ts # Módulo raíz
+└── main.ts # Entry point
+```
+
+### Flujo de autenticación
+
+1. **Usuario accede a `/auth/google`**
+ - El `GoogleAuthGuard` intercepta la request
+ - Redirige al usuario a la página de consentimiento de Google
+
+2. **Usuario autoriza en Google**
+ - Google redirige a `/auth/google/callback` con un código de autorización
+ - El `GoogleStrategy` intercambia el código por un access token
+ - Se obtiene el perfil del usuario (email, nombre, foto)
+
+3. **Validación y creación/actualización de usuario**
+ - `AuthService.validateGoogleUser()` busca el usuario por `google_id` o `email`
+ - Si existe: actualiza `ultimo_login` y `avatar_url`
+ - Si no existe: crea nuevo usuario con rol `estudiante` y estado `activo`
+ - Si está suspendido/inactivo: lanza excepción
+
+4. **Generación de JWT**
+ - Se crea un token JWT con payload: `{ sub: userId, email, rol }`
+ - El token se firma con `JWT_SECRET` y expira según `JWT_EXPIRATION`
+
+5. **Redirección al gateway**
+ - Se redirige a `{GATEWAY_URL}/wise/auth/callback?token={JWT}&user={USER_DATA}`
+ - El gateway maneja el resto del flujo (almacenar token, redirigir al frontend)
+
+### Sistema de guards
+
+El proyecto usa guards globales aplicados en `main.ts`:
+
+1. **JwtAuthGuard**: Valida el token JWT en todas las rutas excepto las marcadas con `@Public()`
+2. **RolesGuard**: Verifica que el usuario tenga los roles requeridos (si se especifican con `@Roles()`)
+
+El orden importa: primero se valida el JWT, luego los roles.
+
+### Sistema de caché
+
+El sistema de caché está diseñado para optimizar consultas frecuentes:
+
+- **Estrategia**: Redis (si está configurado) o memoria (fallback)
+- **TTL por tipo de dato**:
+ - Estadísticas generales: 5 minutos
+ - Estadísticas por rol: 5 minutos
+ - Crecimiento de usuarios: 10 minutos
+ - Listas paginadas: 2 minutos
+- **Invalidación**: Automática al crear/actualizar/eliminar usuarios
+- **Registro de claves**: Sistema de registro para invalidar múltiples claves relacionadas
---
-## 🧪 Testing
+## API Endpoints
-### Ejecutar todos los tests unitarios
-```bash
-npm run test
+### Autenticación
+
+#### `GET /auth/google`
+Inicia el flujo OAuth 2.0 con Google. Redirige automáticamente a la página de consentimiento de Google.
+
+**Nota**: Este endpoint no se puede probar directamente desde Swagger. Debes acceder desde el navegador.
+
+#### `GET /auth/google/callback`
+Callback de Google que procesa la autenticación y redirige al gateway con el token JWT.
+
+**Response**: Redirección 307 al gateway con query params:
+- `token`: JWT token
+- `user`: Datos del usuario en JSON
+
+### Gestión de Usuarios
+
+Todos los endpoints de gestión requieren autenticación JWT.
+
+#### `GET /gestion-usuarios`
+Lista usuarios con filtros y paginación. Solo administradores.
+
+**Query params**:
+- `page` (default: 1): Número de página
+- `limit` (default: 10): Resultados por página
+- `search` (opcional): Búsqueda por nombre, apellido o email
+- `rolId` (opcional): Filtrar por rol (1=estudiante, 2=tutor, 3=admin)
+- `estadoId` (opcional): Filtrar por estado (1=activo, 2=inactivo, 3=suspendido)
+
+**Response**:
+```json
+{
+ "data": [...],
+ "meta": {
+ "total": 100,
+ "page": 1,
+ "limit": 10,
+ "totalPages": 10
+ }
+}
```
-### Tests en modo watch (desarrollo)
-```bash
-npm run test:watch
+#### `PATCH /gestion-usuarios/:id/rol`
+Cambia el rol de un usuario. Solo administradores.
+
+**Body**:
+```json
+{
+ "rolId": 2
+}
```
-Los tests se ejecutarán automáticamente al detectar cambios.
-### Tests end-to-end (e2e)
-```bash
-npm run test:e2e
+#### `PATCH /gestion-usuarios/:id/estado`
+Cambia el estado de un usuario. Solo administradores.
+
+**Body**:
+```json
+{
+ "estadoId": 3
+}
```
-Prueban el flujo completo de la aplicación.
-### Generar reporte de cobertura
-```bash
-npm run test:cov
+#### `PATCH /gestion-usuarios/me/info-personal`
+Actualiza la información personal del usuario autenticado (teléfono, biografía).
+
+**Body**:
+```json
+{
+ "telefono": "+57 300 123 4567",
+ "biografia": "Estudiante de ingeniería..."
+}
```
-Los reportes se generan en la carpeta `coverage/`
-### Modo debug para tests
-```bash
-npm run test:debug
+#### `DELETE /gestion-usuarios/:id`
+Elimina un usuario. Solo administradores.
+
+#### `DELETE /gestion-usuarios/me/cuenta`
+Elimina la cuenta del usuario autenticado.
+
+### Estadísticas
+
+Todos los endpoints de estadísticas requieren rol de administrador.
+
+#### `GET /gestion-usuarios/estadisticas/usuarios`
+Obtiene estadísticas generales de usuarios (totales, activos, suspendidos, inactivos).
+
+**Response**:
+```json
+{
+ "resumen": {
+ "total": 100,
+ "activos": {
+ "conteo": 75,
+ "porcentaje": 75.00
+ },
+ "suspendidos": {
+ "conteo": 15,
+ "porcentaje": 15.00
+ },
+ "inactivos": {
+ "conteo": 10,
+ "porcentaje": 10.00
+ }
+ }
+}
```
-Permite depurar tests con Node Inspector.
-### Estructura de tests
+#### `GET /gestion-usuarios/estadisticas/roles`
+Obtiene estadísticas de usuarios por rol.
+
+**Response**:
+```json
+{
+ "totalUsuarios": 100,
+ "roles": [
+ {
+ "rolId": 1,
+ "rol": "estudiante",
+ "conteo": 75,
+ "porcentaje": 75.00
+ },
+ ...
+ ]
+}
```
-test/
-├── app.e2e-spec.ts # Tests end-to-end
-└── jest-e2e.json # Configuración Jest E2E
-src/
-└── **/*.spec.ts # Tests unitarios junto al código
+#### `GET /gestion-usuarios/estadisticas/crecimiento?weeks=12`
+Obtiene el crecimiento de usuarios por semana. Por defecto 12 semanas, máximo 52.
+
+**Query params**:
+- `weeks` (opcional, default: 12): Número de semanas a analizar
+
+**Response**:
+```json
+{
+ "periodo": {
+ "inicio": "2024-09-15T00:00:00.000Z",
+ "fin": "2024-12-07T00:00:00.000Z",
+ "semanas": 12
+ },
+ "totalUsuariosNuevos": 150,
+ "data": [
+ {
+ "semana": "2024-W38",
+ "conteo": 10,
+ "fecha": "15 sep"
+ },
+ ...
+ ]
+}
```
---
-## 📖 Documentación de API
+## Documentación Swagger
-### Swagger UI
-
-Este microservicio incluye documentación interactiva de la API mediante **Swagger/OpenAPI**.
-
-#### Acceder a Swagger UI
-
-Con el servidor en ejecución, abre tu navegador en:
+La API está completamente documentada con Swagger/OpenAPI. Una vez que el servidor esté corriendo, accede a:
```
http://localhost:3000/api/docs
```
-#### Características de Swagger:
-- 📚 **Explorar endpoints**: Visualiza todos los endpoints disponibles con sus descripciones
-- 🧪 **Probar API**: Ejecuta requests directamente desde el navegador
-- 📋 **Esquemas de datos**: Ve la estructura de requests y responses con ejemplos
-- 🔒 **Autenticación**: Prueba endpoints protegidos con JWT usando el botón "Authorize"
-- 💡 **Ejemplos**: Cada endpoint incluye ejemplos de uso
+### Características
-#### Endpoints Documentados:
+- **Exploración interactiva**: Prueba todos los endpoints directamente desde el navegador
+- **Autenticación JWT**: Usa el botón "Authorize" para agregar tu token JWT
+- **Esquemas de datos**: Ve la estructura completa de requests y responses
+- **Ejemplos**: Cada endpoint incluye ejemplos de uso
-**Autenticación**
-- `GET /auth/google` - Inicia el flujo OAuth 2.0 con Google
-- `GET /auth/google/callback` - Callback de Google que retorna JWT
+### Cómo usar JWT en Swagger
-#### Usar JWT en Swagger:
-
-1. Obtén un token mediante el flujo de autenticación
-2. Click en el botón **"Authorize"** (🔓) en la parte superior
+1. Obtén un token mediante el flujo de autenticación (`/auth/google`)
+2. Haz click en el botón **"Authorize"** (🔓) en la parte superior
3. Ingresa: `Bearer `
-4. Click en "Authorize"
-5. Ahora puedes probar endpoints protegidos
-
-> 📘 Para más detalles sobre Swagger, consulta: [docs/API_DOCUMENTATION.md](./docs/API_DOCUMENTATION.md)
+4. Haz click en "Authorize"
+5. Ahora puedes probar todos los endpoints protegidos
---
-## 📚 Arquitectura del Proyecto
+## Sistema de Roles y Estados
-```
-src/
-├── auth/ # Módulo de autenticación
-│ ├── decorators/ # Decoradores personalizados
-│ │ ├── get-user.decorator.ts # Extrae usuario del request
-│ │ ├── public.decorator.ts # Marca rutas públicas
-│ │ └── roles.decorator.ts # Define roles requeridos
-│ ├── dto/ # Data Transfer Objects
-│ │ ├── auth-response.dto.ts # Respuesta de autenticación
-│ │ └── google-user.dto.ts # Datos de usuario de Google
-│ ├── enums/ # Enumeraciones
-│ │ └── role.enum.ts # Roles y estados
-│ ├── guards/ # Guards de protección
-│ │ ├── google-auth.guard.ts # Guard OAuth Google
-│ │ ├── jwt-auth.guard.ts # Guard JWT
-│ │ └── roles.guard.ts # Guard de roles
-│ ├── strategies/ # Estrategias de Passport
-│ │ ├── google.strategy.ts # Estrategia OAuth Google
-│ │ └── jwt.strategy.ts # Estrategia JWT
-│ ├── auth.controller.ts # Controlador de rutas
-│ ├── auth.module.ts # Módulo de autenticación
-│ ├── auth.service.ts # Lógica de negocio
-│ └── index.ts # Exports públicos
-├── config/ # Configuración
-│ ├── envs.ts # Variables de entorno validadas
-│ └── index.ts # Exports de configuración
-├── prisma/ # Módulo Prisma
-│ ├── prisma.module.ts # Módulo Prisma
-│ └── prisma.service.ts # Servicio Prisma
-├── app.module.ts # Módulo raíz
-└── main.ts # Entry point
-```
+### Roles
----
+Los roles se almacenan en la tabla `roles` y se relacionan con usuarios mediante `rolId`:
+
+- **estudiante** (ID: 1) - Rol por defecto para nuevos usuarios
+- **tutor** (ID: 2) - Usuarios con permisos de tutoría
+- **admin** (ID: 3) - Administradores con permisos completos
-## 🔒 Sistema de Roles
+### Estados
-### Roles disponibles:
-- **estudiante**: Usuario básico del sistema (rol por defecto)
-- **tutor**: Usuario con permisos de tutoría
-- **admin**: Administrador con permisos completos
+Los estados se almacenan en la tabla `estados_usuario`:
-### Uso de decoradores:
+- **activo** (ID: 1) - Estado por defecto
+- **inactivo** (ID: 2) - Usuario inactivo
+- **suspendido** (ID: 3) - Usuario suspendido (no puede iniciar sesión)
+
+### Uso de decoradores
```typescript
import { Roles } from './auth/decorators/roles.decorator';
import { Role } from './auth/enums/role.enum';
+import { Public } from './auth/decorators/public.decorator';
+import { GetUser } from './auth/decorators/get-user.decorator';
// Solo admin puede acceder
@Roles(Role.ADMIN)
@@ -324,55 +516,94 @@ publicRoute() {
getProfile(@GetUser() user) {
return user;
}
+
+// Extraer solo el ID del usuario
+@Get('my-id')
+getMyId(@GetUser('id') userId: string) {
+ return { userId };
+}
```
---
-## 🗄️ Base de Datos
+## Base de Datos
### Modelo de Usuario
-```prisma
-model Usuario {
- id String @id @default(uuid())
- email String @unique
- nombre String
- apellido String
- telefono String?
- semestre Int @default(1)
- google_id String? @unique
- avatar_url String?
- rol RolEnum @default(estudiante)
- estado EstadoUsuario @default(activo)
- email_verificado Boolean @default(false)
- ultimo_login DateTime?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-}
-```
+El modelo principal es `Usuario` con las siguientes características:
+
+- **Identificación**: `id` (UUID), `email` (único), `google_id` (único, opcional)
+- **Datos personales**: `nombre`, `apellido`, `telefono`, `biografia`, `semestre`
+- **Autenticación**: `google_id`, `avatar_url`, `ultimo_login`
+- **Relaciones**: `rolId` → `Rol`, `estadoId` → `EstadoUsuario`
+- **Timestamps**: `createdAt`, `updatedAt`
+
+### Relaciones importantes
+
+El schema incluye relaciones con otros módulos del ecosistema:
+
+- **Notificaciones**: Un usuario tiene muchas notificaciones
+- **Tutorías**: Relaciones con sesiones como tutor o estudiante
+- **Materiales**: Usuarios pueden subir materiales educativos
+- **Ratings**: Usuarios pueden calificar sesiones de tutoría
### Comandos Prisma útiles
```bash
# Crear una nueva migración
-npx prisma migrate dev --name nombre_de_la_migracion
+npx prisma migrate dev --name descripcion_cambio
# Aplicar migraciones en producción
npx prisma migrate deploy
-# Resetear base de datos (solo desarrollo)
+# Resetear base de datos (solo desarrollo - elimina todos los datos)
npx prisma migrate reset
-# Abrir Prisma Studio (interfaz visual)
+# Abrir Prisma Studio (interfaz visual para ver/editar datos)
npx prisma studio
# Generar cliente después de cambios en schema
npx prisma generate
+
+# Ver el estado de las migraciones
+npx prisma migrate status
```
---
-## 📝 Variables de Entorno
+## Testing
+
+### Tests unitarios
+
+```bash
+# Ejecutar todos los tests
+npm run test
+
+# Modo watch (se ejecutan automáticamente al cambiar archivos)
+npm run test:watch
+
+# Con cobertura
+npm run test:cov
+
+# Modo debug
+npm run test:debug
+```
+
+### Tests end-to-end
+
+```bash
+npm run test:e2e
+```
+
+Los tests e2e prueban el flujo completo de la aplicación, incluyendo autenticación y gestión de usuarios.
+
+### Estructura de tests
+
+Los tests unitarios están junto al código que prueban (archivos `.spec.ts`), mientras que los tests e2e están en la carpeta `test/`.
+
+---
+
+## Variables de Entorno
| Variable | Descripción | Ejemplo | Requerido |
|----------|-------------|---------|-----------|
@@ -384,19 +615,26 @@ npx prisma generate
| `GOOGLE_CLIENT_ID` | Client ID de Google OAuth | `123-abc.apps.googleusercontent.com` | ✅ |
| `GOOGLE_CLIENT_SECRET` | Client Secret de Google OAuth | `GOCSPX-abc123` | ✅ |
| `GOOGLE_CALLBACK_URL` | URL de callback de Google | `http://localhost:3000/auth/google/callback` | ✅ |
+| `GATEWAY_URL` | URL del API Gateway | `http://localhost:4000` | ✅ |
+| `SERVICEBUS_CONNECTION_STRING` | Connection string de Azure Service Bus | `Endpoint=sb://...` | ✅ |
+| `REDIS_HOST` | Host de Redis | `localhost` | ⚪ |
+| `REDIS_PORT` | Puerto de Redis | `6380` | ⚪ |
+| `REDIS_PASSWORD` | Password de Redis | `password` | ⚪ |
+
+**Leyenda**: ✅ Requerido | ⚪ Opcional
---
-## 🔧 Scripts Disponibles
+## Scripts Disponibles
| Script | Descripción |
|--------|-------------|
-| `npm run build` | Compila el proyecto TypeScript |
-| `npm run start` | Inicia la aplicación en modo producción |
+| `npm run build` | Compila el proyecto TypeScript a JavaScript |
+| `npm run start` | Inicia la aplicación (genera Prisma, migra DB, ejecuta) |
| `npm run start:dev` | Inicia con hot-reload para desarrollo |
-| `npm run start:debug` | Inicia en modo debug |
-| `npm run start:prod` | Inicia en modo producción |
-| `npm run lint` | Ejecuta ESLint |
+| `npm run start:debug` | Inicia en modo debug (puerto 9229) |
+| `npm run start:prod` | Inicia en modo producción (genera Prisma, migra DB, ejecuta) |
+| `npm run lint` | Ejecuta ESLint para verificar código |
| `npm run format` | Formatea código con Prettier |
| `npm run test` | Ejecuta tests unitarios |
| `npm run test:watch` | Ejecuta tests en modo watch |
@@ -406,22 +644,46 @@ npx prisma generate
---
-## 📝 Convenciones de Commits
+## Consideraciones de Producción
+
+### Seguridad
+
+- **JWT_SECRET**: Usa un secreto fuerte y único. Genera uno con: `openssl rand -base64 32`
+- **HTTPS**: Siempre usa HTTPS en producción
+- **CORS**: Configura los orígenes permitidos correctamente
+- **Rate Limiting**: Considera implementar rate limiting para prevenir abusos
+- **Variables de entorno**: Nunca commitees archivos `.env`
+
+### Performance
+
+- **Redis**: Usa Redis en producción para el sistema de caché
+- **Connection Pooling**: Prisma maneja el pooling automáticamente, pero revisa la configuración
+- **Índices**: El schema de Prisma incluye índices en campos frecuentemente consultados
+
+### Monitoreo
+
+- **Logging**: El proyecto usa el Logger de NestJS. Considera integrar con un servicio de logging centralizado
+- **Health Checks**: Considera agregar endpoints de health check para monitoreo
+- **Métricas**: Considera agregar métricas de performance (tiempo de respuesta, errores, etc.)
+
+---
+
+## Convenciones de Commits
-Este proyecto sigue [Conventional Commits](https://www.conventionalcommits.org/) para mantener un historial claro y consistente.
+Este proyecto sigue [Conventional Commits](https://www.conventionalcommits.org/).
-### Formato Básico
+### Formato
```
():
```
-### Tipos Principales
+### Tipos
- `feat` - Nueva funcionalidad
- `fix` - Corrección de bug
- `docs` - Cambios en documentación
-- `style` - Cambios de formato
+- `style` - Cambios de formato (no afectan funcionalidad)
- `refactor` - Refactorización de código
- `test` - Añadir o modificar tests
- `chore` - Tareas de mantenimiento
@@ -429,77 +691,55 @@ Este proyecto sigue [Conventional Commits](https://www.conventionalcommits.org/)
### Ejemplos
```bash
-feat(auth): agregar autenticación con Facebook
-fix(jwt): corregir validación de tokens expirados
+feat(auth): agregar validación de usuarios suspendidos
+fix(jwt): corregir expiración de tokens
docs(readme): actualizar instrucciones de instalación
-test(auth): aumentar cobertura de Google OAuth
+refactor(cache): optimizar invalidación de caché
+test(auth): agregar tests para Google OAuth
+chore(deps): actualizar dependencias
```
-> 📘 **Documentación completa:** Ver [COMMITS.md](./COMMITS.md) para guía detallada de convenciones de commits
-
---
-## 📄 Licencia
+## Troubleshooting
-Este proyecto es privado y pertenece a DOSW2025.
+### Error: "Prisma Client not generated"
----
+```bash
+npx prisma generate
+```
-## 👥 Equipo
+### Error: "Database connection failed"
-**DOSW2025** - Desarrollo de Aplicaciones Web
+Verifica que:
+- PostgreSQL esté corriendo
+- Las credenciales en `DATABASE_URL` sean correctas
+- La base de datos exista
----
+### Error: "Google OAuth failed"
+
+Verifica que:
+- `GOOGLE_CLIENT_ID` y `GOOGLE_CLIENT_SECRET` sean correctos
+- `GOOGLE_CALLBACK_URL` coincida con la configurada en Google Cloud Console
+- Los orígenes autorizados incluyan tu dominio
-## 📞 Soporte
+### Error: "Redis connection failed"
-Para preguntas o problemas:
-- Crear un issue en el repositorio
-- Contactar al equipo de desarrollo
+Si Redis no está disponible, el sistema usará caché en memoria automáticamente. Para producción, asegúrate de que Redis esté configurado correctamente.
+
+### Error: "ServiceBus sender not initialized"
+
+Verifica que `SERVICEBUS_CONNECTION_STRING` sea correcto y que el servicio tenga permisos para enviar mensajes a la cola `mail.envio.individual`.
---
-## 🔗 Enlaces Útiles
+## Enlaces Útiles
- [NestJS Documentation](https://docs.nestjs.com)
- [Prisma Documentation](https://www.prisma.io/docs)
- [Passport.js Documentation](http://www.passportjs.org/docs/)
- [Google OAuth 2.0 Guide](https://developers.google.com/identity/protocols/oauth2)
-
-
-## Description
-
-[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
-
-## Project setup
+- [Azure Service Bus Documentation](https://docs.microsoft.com/azure/service-bus-messaging/)
-```bash
-$ npm install
-```
-
-## Compile and run the project
-
-```bash
-# development
-$ npm run start
-
-# watch mode
-$ npm run start:dev
-
-# production mode
-$ npm run start:prod
-```
-
-## Run tests
-
-```bash
-# unit tests
-$ npm run test
-
-# e2e tests
-$ npm run test:e2e
+---
-# test coverage
-$ npm run test:cov
-```
diff --git a/package-lock.json b/package-lock.json
index 6ca01c8..d1599d9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,11 +8,11 @@
"name": "wise_auth",
"version": "0.0.1",
"hasInstallScript": true,
- "hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
"@azure/identity": "^4.13.0",
"@azure/service-bus": "^7.9.5",
+ "@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.1",
@@ -23,10 +23,10 @@
"@nestjs/swagger": "^11.2.2",
"@prisma/adapter-pg": "^7.0.1",
"@prisma/client": "^7.0.1",
- "@prisma/adapter-pg": "^7.0.1",
- "@prisma/client": "^7.0.1",
"@types/passport-google-oauth20": "^2.0.17",
"bcrypt": "^6.0.0",
+ "cache-manager": "^7.2.6",
+ "cache-manager-redis-yet": "^5.1.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"dotenv": "^17.2.3",
@@ -36,8 +36,7 @@
"passport-jwt": "^4.0.1",
"pg": "^8.16.3",
"prisma": "^7.0.1",
- "pg": "^8.16.3",
- "prisma": "^7.0.1",
+ "redis": "^5.10.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
@@ -53,7 +52,6 @@
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.15.6",
- "@types/pg": "^8.15.6",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
@@ -1036,6 +1034,16 @@
"url": "https://github.com/sponsors/Borewit"
}
},
+ "node_modules/@cacheable/utils": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.2.tgz",
+ "integrity": "sha512-8kGE2P+HjfY8FglaOiW+y8qxcaQAfAhVML+i66XJR3YX5FtyDqn6Txctr3K2FrbxLKixRRYYBWMbuGciOhYNDg==",
+ "license": "MIT",
+ "dependencies": {
+ "hashery": "^1.2.0",
+ "keyv": "^5.5.4"
+ }
+ },
"node_modules/@chevrotain/cst-dts-gen": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-10.5.0.tgz",
@@ -2489,6 +2497,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@keyv/serialize": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
+ "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
+ "license": "MIT"
+ },
"node_modules/@lukeed/csprng": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz",
@@ -2530,6 +2544,19 @@
"@tybys/wasm-util": "^0.10.0"
}
},
+ "node_modules/@nestjs/cache-manager": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-3.0.1.tgz",
+ "integrity": "sha512-4UxTnR0fsmKL5YDalU2eLFVnL+OBebWUpX+hEduKGncrVKH4PPNoiRn1kXyOCjmzb0UvWgqubpssNouc8e0MCw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@nestjs/common": "^9.0.0 || ^10.0.0 || ^11.0.0",
+ "@nestjs/core": "^9.0.0 || ^10.0.0 || ^11.0.0",
+ "cache-manager": ">=6",
+ "keyv": ">=5",
+ "rxjs": "^7.8.1"
+ }
+ },
"node_modules/@nestjs/cli": {
"version": "11.0.10",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.10.tgz",
@@ -3393,6 +3420,72 @@
"react-dom": "^18.0.0 || ^19.0.0"
}
},
+ "node_modules/@redis/bloom": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
+ "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/client": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
+ "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "cluster-key-slot": "1.1.2",
+ "generic-pool": "3.9.0",
+ "yallist": "4.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@redis/client/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ },
+ "node_modules/@redis/graph": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
+ "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/json": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
+ "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/search": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
+ "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/time-series": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
+ "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
@@ -5328,6 +5421,84 @@
"url": "https://dotenvx.com"
}
},
+ "node_modules/cache-manager": {
+ "version": "7.2.6",
+ "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.6.tgz",
+ "integrity": "sha512-brlumw/nDaCO/AeJGoTWDNYvTRrr9y7n8o4hbE642hr6OPdnNdl2IPiq7d560iDUa9AM0Xe8CAYJPSPFEVTnDA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@cacheable/utils": "^2.3.2",
+ "keyv": "5.5.4"
+ }
+ },
+ "node_modules/cache-manager-redis-yet": {
+ "version": "5.1.5",
+ "resolved": "https://registry.npmjs.org/cache-manager-redis-yet/-/cache-manager-redis-yet-5.1.5.tgz",
+ "integrity": "sha512-NYDxrWBoLXxxVPw4JuBriJW0f45+BVOAsgLiozRo4GoJQyoKPbueQWYStWqmO73/AeHJeWrV7Hzvk6vhCGHlqA==",
+ "deprecated": "With cache-manager v6 we now are using Keyv",
+ "license": "MIT",
+ "dependencies": {
+ "@redis/bloom": "^1.2.0",
+ "@redis/client": "^1.6.0",
+ "@redis/graph": "^1.1.1",
+ "@redis/json": "^1.0.7",
+ "@redis/search": "^1.2.0",
+ "@redis/time-series": "^1.1.0",
+ "cache-manager": "^5.7.6",
+ "redis": "^4.7.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/cache-manager-redis-yet/node_modules/cache-manager": {
+ "version": "5.7.6",
+ "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.7.6.tgz",
+ "integrity": "sha512-wBxnBHjDxF1RXpHCBD6HGvKER003Ts7IIm0CHpggliHzN1RZditb7rXoduE1rplc2DEFYKxhLKgFuchXMJje9w==",
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^5.0.1",
+ "lodash.clonedeep": "^4.5.0",
+ "lru-cache": "^10.2.2",
+ "promise-coalesce": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/cache-manager-redis-yet/node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "license": "ISC"
+ },
+ "node_modules/cache-manager-redis-yet/node_modules/redis": {
+ "version": "4.7.1",
+ "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
+ "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
+ "license": "MIT",
+ "workspaces": [
+ "./packages/*"
+ ],
+ "dependencies": {
+ "@redis/bloom": "1.2.0",
+ "@redis/client": "1.6.1",
+ "@redis/graph": "1.1.1",
+ "@redis/json": "1.0.7",
+ "@redis/search": "1.2.0",
+ "@redis/time-series": "1.1.0"
+ }
+ },
+ "node_modules/cache-manager/node_modules/keyv": {
+ "version": "5.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.4.tgz",
+ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@keyv/serialize": "^1.1.1"
+ }
+ },
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
@@ -5658,6 +5829,15 @@
"node": ">=0.8"
}
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -6542,6 +6722,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/eventemitter3": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
+ "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
+ "license": "MIT"
+ },
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -6913,6 +7099,16 @@
"node": ">=16"
}
},
+ "node_modules/flat-cache/node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
@@ -7126,6 +7322,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/generic-pool": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
+ "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -7414,6 +7619,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/hashery": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.3.0.tgz",
+ "integrity": "sha512-fWltioiy5zsSAs9ouEnvhsVJeAXRybGCNNv0lvzpzNOSDbULXRy7ivFWwCCv4I5Am6kSo75hmbsCduOoc2/K4w==",
+ "license": "MIT",
+ "dependencies": {
+ "hookified": "^1.13.0"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -7436,6 +7653,12 @@
"node": ">=16.9.0"
}
},
+ "node_modules/hookified": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.13.0.tgz",
+ "integrity": "sha512-6sPYUY8olshgM/1LDNW4QZQN0IqgKhtl/1C8koNZBJrKLBk3AZl6chQtNwpNztvfiApHMEwMHek5rv993PRbWw==",
+ "license": "MIT"
+ },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -8940,13 +9163,13 @@
}
},
"node_modules/keyv": {
- "version": "4.5.4",
- "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
- "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
- "dev": true,
+ "version": "5.5.5",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz",
+ "integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==",
"license": "MIT",
+ "peer": true,
"dependencies": {
- "json-buffer": "3.0.1"
+ "@keyv/serialize": "^1.1.1"
}
},
"node_modules/leven": {
@@ -9050,6 +9273,12 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
+ "node_modules/lodash.clonedeep": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
+ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -10434,6 +10663,15 @@
"node": ">= 0.6.0"
}
},
+ "node_modules/promise-coalesce": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.5.0.tgz",
+ "integrity": "sha512-cTJ30U+ur1LD7pMPyQxiKIwxjtAjLsyU7ivRhVWZrX9BNIXtf78pc37vSMc8Vikx7DVzEKNk2SEJ5KWUpSG2ig==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/proper-lockfile": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
@@ -10628,6 +10866,83 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/redis": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/redis/-/redis-5.10.0.tgz",
+ "integrity": "sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw==",
+ "license": "MIT",
+ "dependencies": {
+ "@redis/bloom": "5.10.0",
+ "@redis/client": "5.10.0",
+ "@redis/json": "5.10.0",
+ "@redis/search": "5.10.0",
+ "@redis/time-series": "5.10.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/redis/node_modules/@redis/bloom": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.10.0.tgz",
+ "integrity": "sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "@redis/client": "^5.10.0"
+ }
+ },
+ "node_modules/redis/node_modules/@redis/client": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.10.0.tgz",
+ "integrity": "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "cluster-key-slot": "1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/redis/node_modules/@redis/json": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.10.0.tgz",
+ "integrity": "sha512-B2G8XlOmTPUuZtD44EMGbtoepQG34RCDXLZbjrtON1Djet0t5Ri7/YPXvL9aomXqP8lLTreaprtyLKF4tmXEEA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "@redis/client": "^5.10.0"
+ }
+ },
+ "node_modules/redis/node_modules/@redis/search": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.10.0.tgz",
+ "integrity": "sha512-3SVcPswoSfp2HnmWbAGUzlbUPn7fOohVu2weUQ0S+EMiQi8jwjL+aN2p6V3TI65eNfVsJ8vyPvqWklm6H6esmg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "@redis/client": "^5.10.0"
+ }
+ },
+ "node_modules/redis/node_modules/@redis/time-series": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.10.0.tgz",
+ "integrity": "sha512-cPkpddXH5kc/SdRhF0YG0qtjL+noqFT0AcHbQ6axhsPsO7iqPi1cjxgdkE9TNeKiBUUdCaU1DbqkR/LzbzPBhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "@redis/client": "^5.10.0"
+ }
+ },
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
diff --git a/package.json b/package.json
index 730537b..0b8ce0e 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"dependencies": {
"@azure/identity": "^4.13.0",
"@azure/service-bus": "^7.9.5",
+ "@nestjs/cache-manager": "^3.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.1",
@@ -38,6 +39,8 @@
"@prisma/client": "^7.0.1",
"@types/passport-google-oauth20": "^2.0.17",
"bcrypt": "^6.0.0",
+ "cache-manager": "^7.2.6",
+ "cache-manager-redis-yet": "^5.1.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"dotenv": "^17.2.3",
@@ -47,6 +50,7 @@
"passport-jwt": "^4.0.1",
"pg": "^8.16.3",
"prisma": "^7.0.1",
+ "redis": "^5.10.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
diff --git a/src/app.module.ts b/src/app.module.ts
index 922210a..121a935 100644
--- a/src/app.module.ts
+++ b/src/app.module.ts
@@ -1,10 +1,70 @@
-import { Module } from '@nestjs/common';
+import { Module, Logger } from '@nestjs/common';
+import { CacheModule } from '@nestjs/cache-manager';
+import { redisStore } from 'cache-manager-redis-yet';
import { AuthModule } from './auth/auth.module';
import { PrismaModule } from './prisma/prisma.module';
import { GestionUsuariosModule } from './gestion-usuarios/gestion-usuarios.module';
+import { envs } from './config';
@Module({
- imports: [AuthModule, PrismaModule, GestionUsuariosModule],
+ imports: [
+ CacheModule.registerAsync({
+ isGlobal: true,
+ useFactory: async () => {
+ const logger = new Logger('CacheModule');
+
+ // Si hay configuración de Redis, usar Redis; si no, usar memoria
+ if (envs.redisHost && envs.redisPort && envs.redisPassword) {
+ logger.log('Configurando Redis cache...');
+ try {
+ const store = await redisStore({
+ socket: {
+ host: envs.redisHost,
+ port: envs.redisPort,
+ tls: true,
+ // Tiempo máximo de espera para conectar (10 segundos)
+ connectTimeout: 10000,
+ // Mantener la conexión viva enviando pings cada 5 segundos
+ keepAlive: 5000,
+ // Estrategia de reconexión automática
+ reconnectStrategy: (retries) => {
+ if (retries > 10) {
+ logger.error('Demasiados intentos de reconexion a Redis. Fallando...');
+ return new Error('Demasiados intentos de reconexion a Redis');
+ }
+ // Backoff exponencial: 100ms, 200ms, 400ms, 800ms, etc. (máximo 3 segundos)
+ const delay = Math.min(retries * 100, 3000);
+ logger.warn(`Reconectando a Redis en ${delay}ms (intento ${retries}/10)`);
+ return delay;
+ },
+ },
+ password: envs.redisPassword,
+ ttl: 300000, // 5 minutos por defecto
+ });
+
+ logger.log('Redis cache configurado exitosamente');
+ return { store };
+ } catch (error) {
+ logger.error(`Error al conectar con Redis: ${error.message}`);
+ logger.warn('Fallback: usando cache en memoria');
+ return {
+ ttl: 300000, // 5 minutos
+ max: 100, // máximo número de items en cache
+ };
+ }
+ } else {
+ logger.log('Usando cache en memoria (Redis no configurado)');
+ return {
+ ttl: 300000, // 5 minutos
+ max: 100, // maximo numero de items en cache
+ };
+ }
+ },
+ }),
+ AuthModule,
+ PrismaModule,
+ GestionUsuariosModule,
+ ],
controllers: [],
providers: [],
})
diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts
index 41dce7a..e72d34a 100644
--- a/src/auth/auth.controller.ts
+++ b/src/auth/auth.controller.ts
@@ -5,7 +5,6 @@ import { AuthService } from './auth.service';
import { Public } from './decorators/public.decorator';
import { GoogleAuthGuard } from './guards/google-auth.guard';
import { GoogleUserDto } from './dto/google-user.dto';
-import { AuthResponseDto } from './dto/auth-response.dto';
import { envs } from 'src/config';
interface RequestWithGoogleUser extends Request {
diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts
index 1a782f3..acf9e3b 100644
--- a/src/auth/auth.module.ts
+++ b/src/auth/auth.module.ts
@@ -10,6 +10,7 @@ import { GoogleStrategy } from './strategies/google.strategy';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RolesGuard } from './guards/roles.guard';
import { GoogleAuthGuard } from './guards/google-auth.guard';
+import { ServiceBusClient } from '@azure/service-bus';
@Module({
imports: [
@@ -29,6 +30,12 @@ import { GoogleAuthGuard } from './guards/google-auth.guard';
JwtAuthGuard,
RolesGuard,
GoogleAuthGuard,
+ {
+ provide: ServiceBusClient,
+ useFactory: () => {
+ return new ServiceBusClient(envs.servicebusconnectionstring);
+ },
+ }
],
exports: [JwtStrategy, JwtAuthGuard, RolesGuard, PassportModule],
})
diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts
index d2517e9..3c7d5af 100644
--- a/src/auth/auth.service.ts
+++ b/src/auth/auth.service.ts
@@ -1,26 +1,53 @@
-import { Injectable, Logger, BadRequestException } from '@nestjs/common';
+import { Injectable, Logger, BadRequestException, Inject, OnModuleDestroy } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
+import { CACHE_MANAGER } from '@nestjs/cache-manager';
+import type { Cache } from 'cache-manager';
import { PrismaService } from 'src/prisma/prisma.service';
import { GoogleUserDto } from './dto/google-user.dto';
import { AuthResponseDto } from './dto/auth-response.dto';
-import { DefaultAzureCredential } from '@azure/identity';
-import { ServiceBusClient } from '@azure/service-bus';
-import { envs } from 'src/config';
+import { ServiceBusClient, ServiceBusMessage, ServiceBusSender } from '@azure/service-bus';
import { NotificacionesDto, TemplateNotificacionesEnum } from './dto/notificaciones.dto';
@Injectable()
-export class AuthService {
+export class AuthService implements OnModuleDestroy {
private readonly logger = new Logger(AuthService.name);
- private client: ServiceBusClient;
+ private readonly CACHE_KEY_STATISTICS = 'estadisticas:usuarios';
+ private readonly CACHE_KEY_STATISTICS_ROLES = 'estadisticas:usuarios:roles';
+ private readonly CACHE_KEY_REGISTRY = 'usuarios:cache:registry';
+ private notification;
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
-
+ private readonly client: ServiceBusClient,
+ @Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {
- const credential = new DefaultAzureCredential();
- let connectionString = envs.servicebusconnectionstring;
- this.client = new ServiceBusClient(connectionString);
+ try {
+ this.notification = this.client.createSender('mail.envio.individual');
+ this.logger.log('ServiceBus sender inicializado correctamente');
+ } catch (error) {
+ this.logger.error(`Error al inicializar ServiceBus sender: ${error.message}`, error.stack);
+ throw error;
+ }
+ }
+
+ private async invalidateUserCaches() {
+ // Invalidar todos los cachés de estadísticas
+ await Promise.all([
+ this.cacheManager.del(this.CACHE_KEY_STATISTICS),
+ this.cacheManager.del(this.CACHE_KEY_STATISTICS_ROLES),
+ ]);
+
+ // Obtener registro de claves de cache de usuarios
+ const registry = await this.cacheManager.get(this.CACHE_KEY_REGISTRY) || [];
+
+ // Invalidar todas las claves registradas en paralelo
+ if (registry.length > 0) {
+ await Promise.all(registry.map(key => this.cacheManager.del(key)));
+ }
+
+ // Limpiar el registro
+ await this.cacheManager.del(this.CACHE_KEY_REGISTRY);
}
async validateGoogleUser(googleUserDto: GoogleUserDto): Promise {
@@ -43,6 +70,21 @@ export class AuthService {
if (user) {
this.logger.log(`Usuario encontrado en BD: ${user.email}, google_id: ${user.google_id}`);
+ // Verificar si el usuario está suspendido o inactivo
+ if (user.estado.nombre === 'suspendido') {
+ this.logger.warn(`Intento de login de usuario suspendido: ${user.email}`);
+ throw new BadRequestException(
+ 'Tu cuenta ha sido suspendida. Por favor, contacta al administrador para más información.'
+ );
+ }
+
+ if (user.estado.nombre === 'inactivo') {
+ this.logger.warn(`Intento de login de usuario inactivo: ${user.email}`);
+ throw new BadRequestException(
+ 'Tu cuenta está inactiva. Por favor, contacta al administrador para reactivarla.'
+ );
+ }
+
if (!user.google_id) {
// Usuario existe pero no tiene Google vinculado, lo vinculamos
this.logger.log(`Vinculando cuenta existente con Google: ${googleUserDto.email}`);
@@ -101,8 +143,17 @@ export class AuthService {
},
});
- // Enviar notificación de nuevo usuario al bus de mensajes
- await this.sendNotificacionNuevoUsuario(user.email, `${user.nombre} ${user.apellido}`, user.id);
+ // Invalidar todos los cachés relacionados con usuarios
+ await this.invalidateUserCaches();
+
+ const mensaje: NotificacionesDto = {
+ email: user.email,
+ name: `${user.nombre} ${user.apellido}`,
+ template: TemplateNotificacionesEnum.NUEVO_USUARIO,
+ resumen: `Bienvenid@ ${user.nombre}, tu cuenta ha sido creada exitosamente.`,
+ guardar: true,
+ };
+ await this.sendNotificacionToServiceBus(mensaje);
}
// Construimos el payload del JWT
@@ -139,29 +190,38 @@ export class AuthService {
}
}
- private async sendNotificacionNuevoUsuario(email: string, name: string, id: string) {
+ async sendNotificacionToServiceBus(notificacionDto: NotificacionesDto) {
try {
- const queueName = 'mail.envio.individual';
- const sender = this.client.createSender(queueName);
-
- const notificacion: NotificacionesDto = {
- email,
- name,
- template: TemplateNotificacionesEnum.NUEVO_USUARIO,
- resumen: `Bienvenid@ ${name}, tu cuenta ha sido creada exitosamente.`,
- guardar: true,
- };
+ if (!this.notification) {
+ this.logger.error('ServiceBus sender no está inicializado');
+ throw new Error('ServiceBus sender no disponible');
+ }
- const message = {
- body: notificacion,
+ const Message: ServiceBusMessage = {
+ body: notificacionDto,
+ contentType: 'application/json',
};
- await sender.sendMessages(message);
- await sender.close();
+ this.logger.log(`Enviando notificación a Service Bus para: ${notificacionDto.email}`);
+ this.logger.debug(`Contenido del mensaje: ${JSON.stringify(notificacionDto)}`);
+
+ await this.notification.sendMessages(Message);
- this.logger.log(`Notificación enviada para nuevo usuario: ${email}`);
+ this.logger.log(`Notificación enviada exitosamente a: ${notificacionDto.email}`);
+ } catch (error) {
+ this.logger.error(`Error al enviar notificación a Service Bus: ${error.message}`, error.stack);
+ this.logger.warn(`La creación del usuario continuó a pesar del error en notificaciones`);
+ }
+ }
+
+ async onModuleDestroy() {
+ try {
+ if (this.notification) {
+ await this.notification.close();
+ this.logger.log('ServiceBus sender cerrado correctamente');
+ }
} catch (error) {
- this.logger.error(`Error al enviar notificación: ${error.message}`, error.stack);
+ this.logger.error(`Error al cerrar ServiceBus sender: ${error.message}`, error.stack);
}
}
}
diff --git a/src/config/envs.ts b/src/config/envs.ts
index 5cdc25c..9a9f54d 100644
--- a/src/config/envs.ts
+++ b/src/config/envs.ts
@@ -13,6 +13,9 @@ interface EnvVars {
GOOGLE_CALLBACK_URL: string;
GATEWAY_URL: string;
SERVICEBUS_CONNECTION_STRING: string;
+ REDIS_HOST?: string;
+ REDIS_PORT?: number;
+ REDIS_PASSWORD?: string;
}
const envsSchema = joi
.object({
@@ -26,6 +29,9 @@ const envsSchema = joi
GOOGLE_CALLBACK_URL: joi.string().required(),
GATEWAY_URL: joi.string().required(),
SERVICEBUS_CONNECTION_STRING: joi.string().required(),
+ REDIS_HOST: joi.string().optional(),
+ REDIS_PORT: joi.number().optional(),
+ REDIS_PASSWORD: joi.string().optional(),
})
.unknown(true);
@@ -46,4 +52,7 @@ export const envs = {
googleCallbackUrl: envVars.GOOGLE_CALLBACK_URL,
gatewayUrl: envVars.GATEWAY_URL.startsWith('http') ? envVars.GATEWAY_URL : `https://${envVars.GATEWAY_URL}`,
servicebusconnectionstring: envVars.SERVICEBUS_CONNECTION_STRING,
+ redisHost: envVars.REDIS_HOST,
+ redisPort: envVars.REDIS_PORT,
+ redisPassword: envVars.REDIS_PASSWORD,
};
diff --git a/src/gestion-usuarios/dto/index.ts b/src/gestion-usuarios/dto/index.ts
index 4d0ba72..7d9251f 100644
--- a/src/gestion-usuarios/dto/index.ts
+++ b/src/gestion-usuarios/dto/index.ts
@@ -2,3 +2,4 @@ export { ChangeRoleDto } from './change-role.dto';
export { ChangeStatusDto } from './change-status.dto';
export { UpdatePersonalInfoDto } from './update-personal-info.dto';
export { FilterUsersDto } from './filter-users.dto';
+export { UserGrowthDto } from './user-growth.dto';
diff --git a/src/gestion-usuarios/dto/user-growth.dto.ts b/src/gestion-usuarios/dto/user-growth.dto.ts
new file mode 100644
index 0000000..3365259
--- /dev/null
+++ b/src/gestion-usuarios/dto/user-growth.dto.ts
@@ -0,0 +1,20 @@
+import { IsOptional, IsNumber, Min, Max } from 'class-validator';
+import { Type } from 'class-transformer';
+import { ApiProperty } from '@nestjs/swagger';
+
+export class UserGrowthDto {
+ @ApiProperty({
+ description: 'Número de semanas a mostrar en el gráfico de crecimiento',
+ example: 12,
+ required: false,
+ minimum: 1,
+ maximum: 52,
+ default: 12,
+ })
+ @IsOptional()
+ @Type(() => Number)
+ @IsNumber()
+ @Min(1, { message: 'El número de semanas debe ser al menos 1' })
+ @Max(52, { message: 'El número de semanas no puede exceder 52 (1 año)' })
+ weeks?: number = 12;
+}
diff --git a/src/gestion-usuarios/gestion-usuarios.controller.ts b/src/gestion-usuarios/gestion-usuarios.controller.ts
index 3109958..22f49e2 100644
--- a/src/gestion-usuarios/gestion-usuarios.controller.ts
+++ b/src/gestion-usuarios/gestion-usuarios.controller.ts
@@ -1,7 +1,7 @@
import { Controller, Get, Patch, Delete, Param, Body, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger';
import { GestionUsuariosService } from './gestion-usuarios.service';
-import { ChangeRoleDto, ChangeStatusDto, UpdatePersonalInfoDto, FilterUsersDto } from './dto';
+import { ChangeRoleDto, ChangeStatusDto, UpdatePersonalInfoDto, FilterUsersDto, UserGrowthDto } from './dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../auth/decorators/roles.decorator';
@@ -291,4 +291,172 @@ export class GestionUsuariosController {
deleteMyAccount(@GetUser('id') userId: string) {
return this.gestionUsuariosService.deleteUser(userId);
}
+
+ @Get('estadisticas/usuarios')
+ @Roles(Role.ADMIN)
+ @ApiOperation({
+ summary: 'Obtener estadísticas de usuarios',
+ description: 'Obtiene un informe completo con el conteo y porcentaje de usuarios activos, suspendidos e inactivos. Solo accesible por administradores.',
+ })
+ @ApiResponse({
+ status: 200,
+ description: 'Estadísticas obtenidas exitosamente',
+ schema: {
+ example: {
+ resumen: {
+ total: 100,
+ activos: {
+ conteo: 75,
+ porcentaje: 75.00
+ },
+ suspendidos: {
+ conteo: 15,
+ porcentaje: 15.00
+ },
+ inactivos: {
+ conteo: 10,
+ porcentaje: 10.00
+ }
+ },
+ usuarios: {
+ activos: [
+ {
+ id: '9b1deb3d-3b7d-4bad-9bdd-2b0d70cf0d28',
+ nombre: 'Juan',
+ apellido: 'Pérez',
+ email: 'juan@example.com'
+ }
+ ],
+ suspendidos: [
+ {
+ id: '8a2cdb2c-2a6c-3aac-8acc-1a0c60bc0c17',
+ nombre: 'María',
+ apellido: 'García',
+ email: 'maria@example.com'
+ }
+ ],
+ inactivos: [
+ {
+ id: '7b3dea1b-1b5b-2bba-7bbb-0b1b50ab0b06',
+ nombre: 'Carlos',
+ apellido: 'López',
+ email: 'carlos@example.com'
+ }
+ ]
+ }
+ }
+ }
+ })
+ @ApiResponse({
+ status: 401,
+ description: 'No autorizado - Token JWT inválido o expirado',
+ })
+ @ApiResponse({
+ status: 403,
+ description: 'Prohibido - No tienes permisos de administrador',
+ })
+ stadisticUsers() {
+ return this.gestionUsuariosService.stadisticUsers();
+ }
+
+ @Get('estadisticas/roles')
+ @Roles(Role.ADMIN)
+ @ApiOperation({
+ summary: 'Obtener estadísticas de usuarios por rol',
+ description: 'Obtiene el total de usuarios y cuántos usuarios tienen cada rol (estudiante, tutor, admin). Solo accesible por administradores.',
+ })
+ @ApiResponse({
+ status: 200,
+ description: 'Estadísticas por rol obtenidas exitosamente',
+ schema: {
+ example: {
+ totalUsuarios: 100,
+ roles: [
+ {
+ rolId: 1,
+ rol: 'estudiante',
+ conteo: 75,
+ porcentaje: 75.00
+ },
+ {
+ rolId: 2,
+ rol: 'tutor',
+ conteo: 20,
+ porcentaje: 20.00
+ },
+ {
+ rolId: 3,
+ rol: 'admin',
+ conteo: 5,
+ porcentaje: 5.00
+ }
+ ]
+ }
+ }
+ })
+ @ApiResponse({
+ status: 401,
+ description: 'No autorizado - Token JWT inválido o expirado',
+ })
+ @ApiResponse({
+ status: 403,
+ description: 'Prohibido - No tienes permisos de administrador',
+ })
+ getUsersByRoleStatistics() {
+ return this.gestionUsuariosService.getUsersByRoleStatistics();
+ }
+
+ @Get('estadisticas/crecimiento')
+ @Roles(Role.ADMIN)
+ @ApiOperation({
+ summary: 'Obtener estadísticas de crecimiento de usuarios por semana',
+ description: 'Obtiene el crecimiento de usuarios registrados por semana. Por defecto muestra las últimas 12 semanas. El valor máximo es 52 semanas (1 año).',
+ })
+ @ApiResponse({
+ status: 200,
+ description: 'Estadísticas de crecimiento obtenidas exitosamente',
+ schema: {
+ example: {
+ periodo: {
+ inicio: '2024-09-15T00:00:00.000Z',
+ fin: '2024-12-07T00:00:00.000Z',
+ semanas: 12
+ },
+ totalUsuariosNuevos: 150,
+ data: [
+ {
+ semana: '2024-W38',
+ conteo: 10,
+ fecha: '15 sep'
+ },
+ {
+ semana: '2024-W39',
+ conteo: 15,
+ fecha: '22 sep'
+ },
+ {
+ semana: '2024-W40',
+ conteo: 12,
+ fecha: '29 sep'
+ }
+ ]
+ }
+ }
+ })
+ @ApiResponse({
+ status: 400,
+ description: 'Datos inválidos - El número de semanas debe estar entre 1 y 52',
+ })
+ @ApiResponse({
+ status: 401,
+ description: 'No autorizado - Token JWT inválido o expirado',
+ })
+ @ApiResponse({
+ status: 403,
+ description: 'Prohibido - No tienes permisos de administrador',
+ })
+ getUserGrowthStatistics(@Query() userGrowthDto: UserGrowthDto) {
+ return this.gestionUsuariosService.getUserGrowthByWeek(userGrowthDto.weeks);
+ }
+
}
\ No newline at end of file
diff --git a/src/gestion-usuarios/gestion-usuarios.service.ts b/src/gestion-usuarios/gestion-usuarios.service.ts
index 03c5b5f..0c77fee 100644
--- a/src/gestion-usuarios/gestion-usuarios.service.ts
+++ b/src/gestion-usuarios/gestion-usuarios.service.ts
@@ -1,14 +1,58 @@
-import { Injectable, NotFoundException } from '@nestjs/common';
+import { Injectable, NotFoundException, Inject } from '@nestjs/common';
+import { CACHE_MANAGER } from '@nestjs/cache-manager';
+import type { Cache } from 'cache-manager';
import { PrismaService } from '../prisma/prisma.service';
import { ChangeRoleDto, ChangeStatusDto, UpdatePersonalInfoDto, FilterUsersDto } from './dto';
import { Prisma } from '@prisma/client';
@Injectable()
export class GestionUsuariosService {
- constructor(private readonly prisma: PrismaService) {}
+ private readonly CACHE_KEY_STATISTICS = 'estadisticas:usuarios';
+ private readonly CACHE_KEY_STATISTICS_ROLES = 'estadisticas:usuarios:roles';
+ private readonly CACHE_KEY_GROWTH_PREFIX = 'estadisticas:usuarios:crecimiento:';
+ private readonly CACHE_KEY_REGISTRY = 'usuarios:cache:registry';
+
+ constructor(
+ private readonly prisma: PrismaService,
+ @Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
+ ) {}
+
+ private async invalidateUserCaches() {
+ // Invalidar todos los cachés de estadísticas en paralelo
+ await Promise.all([
+ this.cacheManager.del(this.CACHE_KEY_STATISTICS),
+ this.cacheManager.del(this.CACHE_KEY_STATISTICS_ROLES),
+ ]);
+
+ const growthKeys = [4, 8, 12, 16, 20, 24].map(
+ weeks => `${this.CACHE_KEY_GROWTH_PREFIX}semanas:${weeks}`
+ );
+ await Promise.all(growthKeys.map(key => this.cacheManager.del(key)));
+
+ // Obtener registro de claves de cache de usuarios
+ const registry = await this.cacheManager.get(this.CACHE_KEY_REGISTRY) || [];
+
+ // Invalidar todas las claves registradas en paralelo
+ if (registry.length > 0) {
+ await Promise.all(registry.map(key => this.cacheManager.del(key)));
+ }
+
+ // Limpiar el registro
+ await this.cacheManager.del(this.CACHE_KEY_REGISTRY);
+ }
async findAllWithFilters(filterUsersDto: FilterUsersDto) {
const { page, limit, search, rolId, estadoId } = filterUsersDto;
+
+ // Crear clave de cache unica basada en los filtros
+ const cacheKey = `usuarios:list:page-${page}:limit-${limit}:search-${search || 'none'}:rol-${rolId || 'none'}:estado-${estadoId || 'none'}`;
+
+ // Intentar obtener del cache
+ const cached = await this.cacheManager.get(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
const skip = (page - 1) * limit;
// Construir el objeto where dinámicamente basado en los filtros
@@ -42,28 +86,39 @@ export class GestionUsuariosService {
this.prisma.usuario.count({ where }),
]);
- if (usuarios.length === 0) {
- return {
- data: [],
- message: 'Sin resultado encontrado',
- meta: {
- total: 0,
- page,
- limit,
- totalPages: 0,
- },
- };
+ const resultado = usuarios.length === 0
+ ? {
+ data: [],
+ message: 'Sin resultado encontrado',
+ meta: {
+ total: 0,
+ page,
+ limit,
+ totalPages: 0,
+ },
+ }
+ : {
+ data: usuarios,
+ meta: {
+ total,
+ page,
+ limit,
+ totalPages: Math.ceil(total / limit),
+ },
+ };
+
+ // Guardar en cache por 2 minutos (120000 ms) - mas corto que estadisticas
+ await this.cacheManager.set(cacheKey, resultado, 120000);
+
+ // Registrar la clave de cache para poder invalidarla despues
+ const registry = await this.cacheManager.get(this.CACHE_KEY_REGISTRY) || [];
+ if (!registry.includes(cacheKey)) {
+ registry.push(cacheKey);
+ // Guardar el registro con el mismo TTL que las estadisticas (5 minutos)
+ await this.cacheManager.set(this.CACHE_KEY_REGISTRY, registry, 300000);
}
- return {
- data: usuarios,
- meta: {
- total,
- page,
- limit,
- totalPages: Math.ceil(total / limit),
- },
- };
+ return resultado;
}
async changeRole(userId: string, changeRoleDto: ChangeRoleDto) {
@@ -85,6 +140,9 @@ export class GestionUsuariosService {
},
});
+ // Invalidar caches relacionados con usuarios
+ await this.invalidateUserCaches();
+
return usuario;
}
@@ -107,6 +165,9 @@ export class GestionUsuariosService {
},
});
+ // Invalidar caches relacionados con usuarios
+ await this.invalidateUserCaches();
+
return usuario;
}
@@ -132,6 +193,9 @@ export class GestionUsuariosService {
},
});
+ // Invalidar caches relacionados con usuarios
+ await this.invalidateUserCaches();
+
return usuario;
}
@@ -150,6 +214,223 @@ export class GestionUsuariosService {
where: { id: userId },
});
+ // Invalidar caches relacionados con usuarios
+ await this.invalidateUserCaches();
+
+
+
return { message: 'Usuario eliminado exitosamente', userId };
}
+ async stadisticUsers() {
+ // Intentar obtener del caché
+ const cached = await this.cacheManager.get(this.CACHE_KEY_STATISTICS);
+ if (cached) {
+ return cached;
+ }
+
+ // Ejecutar todos los conteos en paralelo - mucho más rápido que findMany
+ const [totalUsuarios, conteoActivos, conteoSuspendidos, conteoInactivos] = await Promise.all([
+ this.prisma.usuario.count(),
+ this.prisma.usuario.count({
+ where: {
+ estado: {
+ nombre: 'activo'
+ }
+ }
+ }),
+ this.prisma.usuario.count({
+ where: {
+ estado: {
+ nombre: 'suspendido'
+ }
+ }
+ }),
+ this.prisma.usuario.count({
+ where: {
+ estado: {
+ nombre: 'inactivo'
+ }
+ }
+ })
+ ]);
+
+ const porcentajeActivos = totalUsuarios > 0 ? Number(((conteoActivos / totalUsuarios) * 100).toFixed(2)) : 0;
+ const porcentajeSuspendidos = totalUsuarios > 0 ? Number(((conteoSuspendidos / totalUsuarios) * 100).toFixed(2)) : 0;
+ const porcentajeInactivos = totalUsuarios > 0 ? Number(((conteoInactivos / totalUsuarios) * 100).toFixed(2)) : 0;
+
+ const resultado = {
+ resumen: {
+ total: totalUsuarios,
+ activos: {
+ conteo: conteoActivos,
+ porcentaje: porcentajeActivos
+ },
+ suspendidos: {
+ conteo: conteoSuspendidos,
+ porcentaje: porcentajeSuspendidos
+ },
+ inactivos: {
+ conteo: conteoInactivos,
+ porcentaje: porcentajeInactivos
+ }
+ },
+ };
+
+ // Guardar en caché por 5 minutos (300000 ms)
+ await this.cacheManager.set(this.CACHE_KEY_STATISTICS, resultado, 300000);
+
+ return resultado;
+ }
+
+ async getUsersByRoleStatistics() {
+ const CACHE_KEY = 'estadisticas:usuarios:roles';
+
+ // Intentar obtener del caché
+ const cached = await this.cacheManager.get(CACHE_KEY);
+ if (cached) {
+ return cached;
+ }
+
+ // Usar una sola query con groupBy para contar usuarios por rol
+ // Esto es mucho más eficiente que múltiples queries
+ const [totalUsuarios, roleGroups] = await Promise.all([
+ this.prisma.usuario.count(),
+ this.prisma.usuario.groupBy({
+ by: ['rolId'],
+ _count: {
+ id: true,
+ },
+ }),
+ ]);
+
+ // Obtener información de todos los roles en una sola query
+ const roles = await this.prisma.rol.findMany({
+ select: {
+ id: true,
+ nombre: true,
+ },
+ });
+
+ // Crear un map para acceso rápido a los conteos
+ const roleCountMap = new Map(
+ roleGroups.map(group => [group.rolId, group._count.id])
+ );
+
+ // Construir el resultado con todos los roles, incluso si tienen 0 usuarios
+ const roleStats = roles.map(rol => {
+ const conteo = roleCountMap.get(rol.id) || 0;
+ const porcentaje = totalUsuarios > 0
+ ? Number(((conteo / totalUsuarios) * 100).toFixed(2))
+ : 0;
+
+ return {
+ rolId: rol.id,
+ rol: rol.nombre,
+ conteo,
+ porcentaje,
+ };
+ });
+
+ const resultado = {
+ totalUsuarios,
+ roles: roleStats,
+ };
+
+ // Guardar en caché por 5 minutos (300000 ms)
+ await this.cacheManager.set(CACHE_KEY, resultado, 300000);
+
+ return resultado;
+ }
+
+ async getUserGrowthByWeek(weeks: number = 12) {
+ const CACHE_KEY = `estadisticas:usuarios:crecimiento:semanas:${weeks}`;
+
+ // Intentar obtener del caché
+ const cached = await this.cacheManager.get(CACHE_KEY);
+ if (cached) {
+ return cached;
+ }
+
+ // Calcular la fecha de inicio (hace N semanas)
+ const endDate = new Date();
+ const startDate = new Date();
+ startDate.setDate(startDate.getDate() - (weeks * 7));
+
+ // Obtener todos los usuarios creados en el rango de fechas
+ const usuarios = await this.prisma.usuario.findMany({
+ where: {
+ createdAt: {
+ gte: startDate,
+ lte: endDate,
+ },
+ },
+ select: {
+ createdAt: true,
+ },
+ orderBy: {
+ createdAt: 'asc',
+ },
+ });
+
+ // Agrupar usuarios por semana
+ const semanas = new Map();
+
+ // Inicializar todas las semanas con 0
+ for (let i = 0; i < weeks; i++) {
+ const weekStart = new Date(startDate);
+ weekStart.setDate(weekStart.getDate() + (i * 7));
+ const weekKey = this.getWeekKey(weekStart);
+ semanas.set(weekKey, 0);
+ }
+
+ // Contar usuarios por semana
+ usuarios.forEach(usuario => {
+ const weekKey = this.getWeekKey(usuario.createdAt);
+ const current = semanas.get(weekKey) || 0;
+ semanas.set(weekKey, current + 1);
+ });
+
+ // Convertir a array ordenado
+ const data = Array.from(semanas.entries())
+ .sort((a, b) => a[0].localeCompare(b[0]))
+ .map(([semana, conteo]) => ({
+ semana,
+ conteo,
+ fecha: this.parsearSemana(semana),
+ }));
+
+ const resultado = {
+ periodo: {
+ inicio: startDate.toISOString(),
+ fin: endDate.toISOString(),
+ semanas: weeks,
+ },
+ totalUsuariosNuevos: usuarios.length,
+ data,
+ };
+
+ // Guardar en caché por 10 minutos (600000 ms)
+ await this.cacheManager.set(CACHE_KEY, resultado, 600000);
+
+ return resultado;
+ }
+
+ private getWeekKey(date: Date): string {
+ const year = date.getFullYear();
+ const startOfYear = new Date(year, 0, 1);
+ const weekNumber = Math.ceil(((date.getTime() - startOfYear.getTime()) / 86400000 + startOfYear.getDay() + 1) / 7);
+ return `${year}-W${weekNumber.toString().padStart(2, '0')}`;
+ }
+
+ private parsearSemana(weekKey: string): string {
+ const [year, week] = weekKey.split('-W');
+ const startOfYear = new Date(parseInt(year), 0, 1);
+ const weekStart = new Date(startOfYear);
+ weekStart.setDate(startOfYear.getDate() + (parseInt(week) - 1) * 7);
+
+ return weekStart.toLocaleDateString('es-ES', {
+ day: '2-digit',
+ month: 'short'
+ });
+ }
}