Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
764 changes: 502 additions & 262 deletions README.md

Large diffs are not rendered by default.

337 changes: 326 additions & 11 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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"
},
Expand Down
64 changes: 62 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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: [],
})
Expand Down
1 change: 0 additions & 1 deletion src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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],
})
Expand Down
118 changes: 89 additions & 29 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>(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<AuthResponseDto> {
Expand All @@ -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}`);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
}
9 changes: 9 additions & 0 deletions src/config/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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);

Expand All @@ -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,
};
1 change: 1 addition & 0 deletions src/gestion-usuarios/dto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
20 changes: 20 additions & 0 deletions src/gestion-usuarios/dto/user-growth.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading