add bot wsp

This commit is contained in:
Valentin Romero
2026-06-30 22:04:56 -03:00
parent 6859b89262
commit 25d4ba6cc0
166 changed files with 29830 additions and 0 deletions
@@ -0,0 +1,9 @@
// src/common/decorators/user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const User = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user || null;
},
);
+46
View File
@@ -0,0 +1,46 @@
import {
CanActivate,
ExecutionContext,
Injectable,
ForbiddenException,
UnauthorizedException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { UserRole } from '@prisma/client';
@Injectable()
export class AdminGuard implements CanActivate {
constructor(private prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers['authorization'];
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) {
throw new UnauthorizedException('Token no proporcionado');
}
const token = authHeader.substring(7).trim();
const found = await this.prisma.token.findUnique({
where: { token },
include: { user: true },
});
if (!found) {
throw new UnauthorizedException('Token inválido');
}
if (new Date() > found.expiresAt) {
throw new UnauthorizedException('Token expirado');
}
// Verificar que el usuario sea ADMIN
// if (found?.user?.role !== UserRole.ADMIN) {
// throw new ForbiddenException('Acceso denegado: se requieren permisos de administrador');
// }
request.user = found.user;
return true;
}
}
@@ -0,0 +1,40 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AuthTokenGuard implements CanActivate {
constructor(private prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers['authorization'];
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) {
throw new UnauthorizedException('Token no proporcionado');
}
const token = authHeader.substring(7).trim(); // lo que viene después de "Bearer "
const found = await this.prisma.token.findUnique({
where: { token },
include: { user: true },
});
if (!found) {
throw new UnauthorizedException('Token inválido');
}
if (new Date() > found.expiresAt) {
throw new UnauthorizedException('Token expirado');
}
// guardamos el usuario en request para que @User() lo pueda leer
request.user = found?.user || undefined;
return true;
}
}
@@ -0,0 +1,227 @@
import { Body, Controller, Get, Patch, UseGuards, ValidationPipe, UsePipes, Post } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiProperty } from '@nestjs/swagger';
import { SystemConfigService } from './system-config.service';
import { AdminGuard } from './guards/admin.guard';
import { User } from './decoratos/user.decorator';
import { AuthUser } from './types';
import { IsOptional, IsInt, Min, Max, IsDateString } from 'class-validator';
class UpdateSystemConfigDto {
@ApiProperty({
description: 'Intervalo en minutos para desactivar sesiones vencidas',
example: 30,
required: false,
minimum: 1,
})
@IsOptional()
@IsInt()
@Min(1)
sessionCleanupIntervalMinutes?: number;
@ApiProperty({
description: 'Día del mes (1-28) en que se generan las facturas mensuales',
example: 1,
required: false,
minimum: 1,
maximum: 28,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(28)
invoiceGenerationDayOfMonth?: number;
@ApiProperty({
description: 'Tiempo de expiración de los tokens en minutos',
example: 30,
required: false,
minimum: 1,
})
@IsOptional()
@IsInt()
@Min(1)
tokenExpirationMinutes?: number;
@ApiProperty({
description: 'Intervalo en minutos para limpiar tokens expirados',
example: 60,
required: false,
minimum: 1,
})
@IsOptional()
@IsInt()
@Min(1)
tokenCleanupIntervalMinutes?: number;
@ApiProperty({
description: 'Intervalo en días para actualizar fotos de perfil de WhatsApp',
example: 2,
required: false,
minimum: 1,
})
@IsOptional()
@IsInt()
@Min(1)
profilePictureUpdateIntervalDays?: number;
}
class ForceInvoicesDto {
@ApiProperty({
description: 'Fecha del mes para el cual generar facturas (formato ISO 8601)',
example: '2025-11-01T00:00:00.000Z',
required: true,
})
@IsDateString()
targetMonth!: string;
}
@ApiTags('Configuración del Sistema')
@Controller('system-config')
export class SystemConfigController {
constructor(private readonly systemConfigService: SystemConfigService) { }
@Get()
//@UseGuards(AdminGuard)
@ApiOperation({
summary: 'Obtener configuración actual del sistema',
description: 'Retorna todos los parámetros de configuración actuales que controlan los trabajos programados y tokens'
})
@ApiResponse({
status: 200,
description: 'Configuración obtenida exitosamente',
schema: {
example: {
id: 'clxxx123456',
sessionCleanupIntervalMinutes: 30,
invoiceGenerationDayOfMonth: 1,
tokenExpirationMinutes: 30,
tokenCleanupIntervalMinutes: 60,
profilePictureUpdateIntervalDays: 2,
createdAt: '2025-11-10T00:00:00.000Z',
updatedAt: '2025-11-10T00:00:00.000Z'
}
}
})
@ApiResponse({ status: 401, description: 'No autorizado - Token no proporcionado o inválido' })
@ApiResponse({ status: 403, description: 'Acceso denegado - Se requieren permisos de administrador' })
//@ApiBearerAuth()
async getConfig() {
return this.systemConfigService.getConfig();
}
@Patch()
@UseGuards(AdminGuard)
@ApiOperation({
summary: 'Actualizar configuración del sistema',
description: 'Actualiza uno o más parámetros de configuración. Los cambios toman efecto inmediatamente, excepto tokenExpirationMinutes que requiere reinicio.'
})
@ApiResponse({
status: 200,
description: 'Configuración actualizada exitosamente',
schema: {
example: {
id: 'clxxx123456',
sessionCleanupIntervalMinutes: 45,
invoiceGenerationDayOfMonth: 5,
tokenExpirationMinutes: 60,
tokenCleanupIntervalMinutes: 120,
profilePictureUpdateIntervalDays: 3,
createdAt: '2025-11-10T00:00:00.000Z',
updatedAt: '2025-11-10T12:30:00.000Z',
warnings: [
'El cambio en tokenExpirationMinutes requiere reiniciar la aplicación para que JwtModule lo tome en cuenta.'
]
}
}
})
@ApiResponse({ status: 400, description: 'Datos inválidos - Revisa las validaciones de cada campo' })
@ApiResponse({ status: 401, description: 'No autorizado - Token no proporcionado o inválido' })
@ApiResponse({ status: 403, description: 'Acceso denegado - Se requieren permisos de administrador' })
@ApiBearerAuth()
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async updateConfig(
@Body() data: UpdateSystemConfigDto,
@User() user: AuthUser,
) {
console.log('=== CONTROLLER UPDATE CONFIG ===');
console.log('Body received:', JSON.stringify(data, null, 2));
console.log('Body type:', typeof data);
console.log('Body constructor:', data.constructor.name);
console.log('User:', JSON.stringify(user, null, 2));
console.log('Body keys:', Object.keys(data));
console.log('Body values:', Object.values(data));
console.log('invoiceGenerationDayOfMonth type:', typeof data.invoiceGenerationDayOfMonth);
console.log('invoiceGenerationDayOfMonth value:', data.invoiceGenerationDayOfMonth);
console.log('================================');
const result = await this.systemConfigService.updateConfig(data, user);
// Advertencia si se cambió tokenExpirationMinutes
const warnings = [];
if (data.tokenExpirationMinutes !== undefined) {
warnings.push('El cambio en tokenExpirationMinutes requiere reiniciar la aplicación para que JwtModule lo tome en cuenta.');
}
return {
...result,
...(warnings.length > 0 && { warnings })
};
}
@Get('reload')
@UseGuards(AdminGuard)
@ApiOperation({
summary: 'Recargar configuración desde la base de datos',
description: 'Fuerza la recarga de la configuración desde la base de datos en memoria. Útil después de cambios manuales en la BD.'
})
@ApiResponse({
status: 200,
description: 'Configuración recargada exitosamente',
schema: {
example: {
message: 'Configuración recargada exitosamente'
}
}
})
@ApiResponse({ status: 401, description: 'No autorizado - Token no proporcionado o inválido' })
@ApiResponse({ status: 403, description: 'Acceso denegado - Se requieren permisos de administrador' })
@ApiBearerAuth()
async reloadConfig() {
await this.systemConfigService.reloadConfig();
return { message: 'Configuración recargada exitosamente' };
}
@Post('force-invoices')
@UseGuards(AdminGuard)
@ApiOperation({
summary: 'Forzar generación de facturas para un mes específico',
description: 'Genera facturas para todas las sesiones activas y sus asistentes en el mes especificado. Solo crea facturas que no existan previamente.'
})
@ApiResponse({
status: 200,
description: 'Facturas generadas exitosamente',
schema: {
example: {
message: 'Facturas generadas exitosamente para 2025-11',
totalCreated: 15,
details: [
{ sessionId: 'xxx', assistantId: 'yyy', invoiceId: 'zzz' }
]
}
}
})
@ApiResponse({ status: 400, description: 'Datos inválidos - Fecha requerida' })
@ApiResponse({ status: 401, description: 'No autorizado' })
@ApiResponse({ status: 403, description: 'Acceso denegado' })
@ApiBearerAuth()
async forceGenerateInvoices(
@Body() data: ForceInvoicesDto,
@User() user: AuthUser,
) {
const result = await this.systemConfigService.forceGenerateMonthlyInvoices(
new Date(data.targetMonth),
user
);
return result;
}
}
@@ -0,0 +1,20 @@
import { forwardRef, Module } from '@nestjs/common';
import { SystemConfigService } from './system-config.service';
import { SystemConfigController } from './system-config.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { InvoicesModule } from '~/invoices/invoices.module';
import { UserModule } from '~/user/user.module';
import { AuthModule } from '~/auth/auth.module';
@Module({
controllers: [SystemConfigController],
providers: [SystemConfigService],
imports: [
PrismaModule,
forwardRef(() => InvoicesModule),
forwardRef(() => UserModule),
forwardRef(() => AuthModule),
],
exports: [SystemConfigService],
})
export class SystemConfigModule { }
+295
View File
@@ -0,0 +1,295 @@
import { forwardRef, Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '~/prisma/prisma.service';
import { AuthUser } from './types';
import { InvoicesService } from '~/invoices/invoices.service';
import dayjs from 'dayjs';
import { UserService } from '~/user/user.service';
import { UpsertUserDto } from '~/user/user.dto';
import { AuthService } from '~/auth/auth.service';
@Injectable()
export class SystemConfigService implements OnModuleInit {
private readonly logger = new Logger(SystemConfigService.name);
private config: {
sessionCleanupIntervalMinutes: number;
invoiceGenerationDayOfMonth: number;
tokenExpirationMinutes: number;
tokenCleanupIntervalMinutes: number;
profilePictureUpdateIntervalDays: number;
} | null = null;
private firstUrl: string | null = null;
constructor(
private readonly prisma: PrismaService,
@Inject(forwardRef(() => InvoicesService))
private readonly invoicesService: InvoicesService,
private readonly userService: UserService,
@Inject(forwardRef(() => AuthService))
private readonly authService: AuthService,
) { }
async onModuleInit() {
await this.loadConfig();
}
/**
* Carga la configuración desde la base de datos.
* Si no existe, crea una configuración por defecto.
*/
private async loadConfig() {
try {
let config = await this.prisma.systemConfig.findFirst();
if (!config) {
//NOTE cREO EL PRIMER USUARIO DEL SISTEMA
const dataUser: UpsertUserDto = {
name: 'ADMIN',
role: 'ADMIN',
customId: ''
}
const user = await this.userService.upsert(dataUser)
const token = await this.authService.generarToken(user.id, undefined, 1440); // Token válido por 24 horas
this.logger.log(`Primer usuario ADMIN creado. token para iniciar sesión: ${token}`);
this.firstUrl = `${process.env.URL_FRONT}?t=${token}`;
this.logger.log('No se encontró configuración del sistema, creando valores por defecto...');
config = await this.prisma.systemConfig.create({
data: {
sessionCleanupIntervalMinutes: 30,
invoiceGenerationDayOfMonth: 1,
tokenExpirationMinutes: 30,
tokenCleanupIntervalMinutes: 60,
profilePictureUpdateIntervalDays: 2,
},
});
}
this.config = {
sessionCleanupIntervalMinutes: config.sessionCleanupIntervalMinutes,
invoiceGenerationDayOfMonth: config.invoiceGenerationDayOfMonth,
tokenExpirationMinutes: config.tokenExpirationMinutes,
tokenCleanupIntervalMinutes: config.tokenCleanupIntervalMinutes,
profilePictureUpdateIntervalDays: config.profilePictureUpdateIntervalDays,
};
this.logger.log('Configuración del sistema cargada exitosamente');
} catch (err: any) {
this.logger.error('Error cargando configuración del sistema', err?.stack || err);
// Valores por defecto en caso de error
this.config = {
sessionCleanupIntervalMinutes: 30,
invoiceGenerationDayOfMonth: 1,
tokenExpirationMinutes: 30,
tokenCleanupIntervalMinutes: 60,
profilePictureUpdateIntervalDays: 2,
};
}
}
/**
* Recarga la configuración desde la base de datos
*/
async reloadConfig() {
await this.loadConfig();
}
/**
* Obtiene el intervalo de limpieza de sesiones en minutos
*/
getSessionCleanupInterval(): number {
return this.config?.sessionCleanupIntervalMinutes ?? 30;
}
/**
* Obtiene el día del mes para generar facturas (1-28)
*/
getInvoiceGenerationDay(): number {
return this.config?.invoiceGenerationDayOfMonth ?? 1;
}
/**
* Obtiene el tiempo de expiración de tokens en minutos
*/
getTokenExpirationMinutes(): number {
return this.config?.tokenExpirationMinutes ?? 30;
}
/**
* Obtiene el intervalo de limpieza de tokens en minutos
*/
getTokenCleanupInterval(): number {
return this.config?.tokenCleanupIntervalMinutes ?? 60;
}
/**
* Obtiene el intervalo de actualización de fotos de perfil en días
*/
getProfilePictureUpdateInterval(): number {
return this.config?.profilePictureUpdateIntervalDays ?? 2;
}
/**
* Actualiza la configuración del sistema
*/
async updateConfig(data: {
sessionCleanupIntervalMinutes?: number;
invoiceGenerationDayOfMonth?: number;
tokenExpirationMinutes?: number;
tokenCleanupIntervalMinutes?: number;
profilePictureUpdateIntervalDays?: number;
}, actionBy?: AuthUser) {
const actionByInfo = actionBy ? `${actionBy.name || actionBy.id} (${actionBy.role})` : 'Unknown';
this.logger.log(`Update system config request - data: ${JSON.stringify(data)}, actionBy: ${actionByInfo}`);
try {
// Validaciones
if (data.invoiceGenerationDayOfMonth !== undefined) {
if (data.invoiceGenerationDayOfMonth < 1 || data.invoiceGenerationDayOfMonth > 28) {
this.logger.warn(`Invalid invoiceGenerationDayOfMonth: ${data.invoiceGenerationDayOfMonth}, attemptedBy: ${actionByInfo}`);
throw new Error('El día de generación de facturas debe estar entre 1 y 28');
}
}
if (data.sessionCleanupIntervalMinutes !== undefined && data.sessionCleanupIntervalMinutes < 1) {
this.logger.warn(`Invalid sessionCleanupIntervalMinutes: ${data.sessionCleanupIntervalMinutes}, attemptedBy: ${actionByInfo}`);
throw new Error('El intervalo de limpieza de sesiones debe ser mayor a 0');
}
if (data.tokenExpirationMinutes !== undefined && data.tokenExpirationMinutes < 1) {
this.logger.warn(`Invalid tokenExpirationMinutes: ${data.tokenExpirationMinutes}, attemptedBy: ${actionByInfo}`);
throw new Error('El tiempo de expiración de tokens debe ser mayor a 0');
}
if (data.tokenCleanupIntervalMinutes !== undefined && data.tokenCleanupIntervalMinutes < 1) {
this.logger.warn(`Invalid tokenCleanupIntervalMinutes: ${data.tokenCleanupIntervalMinutes}, attemptedBy: ${actionByInfo}`);
throw new Error('El intervalo de limpieza de tokens debe ser mayor a 0');
}
if (data.profilePictureUpdateIntervalDays !== undefined && data.profilePictureUpdateIntervalDays < 1) {
this.logger.warn(`Invalid profilePictureUpdateIntervalDays: ${data.profilePictureUpdateIntervalDays}, attemptedBy: ${actionByInfo}`);
throw new Error('El intervalo de actualización de fotos debe ser mayor a 0');
}
this.logger.log(`Validations passed, searching for existing config - actionBy: ${actionByInfo}`);
// Buscar configuración existente
const existing = await this.prisma.systemConfig.findFirst();
if (!existing) {
this.logger.error(`System config not found in database - attemptedBy: ${actionByInfo}`);
throw new Error('No se encontró configuración del sistema');
}
this.logger.log(`Found existing config - id: ${existing.id}, updating with data: ${JSON.stringify(data)}, by: ${actionByInfo}`);
this.logger.log(`Current values in DB - sessionCleanup: ${existing.sessionCleanupIntervalMinutes}, invoiceDay: ${existing.invoiceGenerationDayOfMonth}, tokenExp: ${existing.tokenExpirationMinutes}, tokenCleanup: ${existing.tokenCleanupIntervalMinutes}`);
console.log('=== SERVICE BEFORE PRISMA UPDATE ===');
console.log('Existing ID:', existing.id);
console.log('Data to update:', JSON.stringify(data, null, 2));
console.log('Data keys:', Object.keys(data));
console.log('Data values:', Object.values(data));
console.log('Data object type:', typeof data);
console.log('Is data empty?', Object.keys(data).length === 0);
// Construir el objeto de actualización explícitamente
const updateData: any = {};
if (data.sessionCleanupIntervalMinutes !== undefined) {
updateData.sessionCleanupIntervalMinutes = data.sessionCleanupIntervalMinutes;
console.log('Adding sessionCleanupIntervalMinutes:', data.sessionCleanupIntervalMinutes);
}
if (data.invoiceGenerationDayOfMonth !== undefined) {
updateData.invoiceGenerationDayOfMonth = data.invoiceGenerationDayOfMonth;
console.log('Adding invoiceGenerationDayOfMonth:', data.invoiceGenerationDayOfMonth);
}
if (data.tokenExpirationMinutes !== undefined) {
updateData.tokenExpirationMinutes = data.tokenExpirationMinutes;
console.log('Adding tokenExpirationMinutes:', data.tokenExpirationMinutes);
}
if (data.tokenCleanupIntervalMinutes !== undefined) {
updateData.tokenCleanupIntervalMinutes = data.tokenCleanupIntervalMinutes;
console.log('Adding tokenCleanupIntervalMinutes:', data.tokenCleanupIntervalMinutes);
}
if (data.profilePictureUpdateIntervalDays !== undefined) {
updateData.profilePictureUpdateIntervalDays = data.profilePictureUpdateIntervalDays;
console.log('Adding profilePictureUpdateIntervalDays:', data.profilePictureUpdateIntervalDays);
}
console.log('Final updateData object:', JSON.stringify(updateData, null, 2));
console.log('UpdateData keys:', Object.keys(updateData));
console.log('====================================');
// Actualizar configuración
const updated = await this.prisma.systemConfig.update({
where: { id: existing.id },
data: updateData,
});
console.log('=== SERVICE AFTER PRISMA UPDATE ===');
console.log('Updated result:', JSON.stringify(updated, null, 2));
console.log('===================================');
this.logger.log(`Database updated successfully - configId: ${existing.id}, newValues: ${JSON.stringify(updated)}, by: ${actionByInfo}`);
// Recargar configuración en memoria
await this.loadConfig();
this.logger.log(`Configuration reloaded in memory - by: ${actionByInfo}`);
this.logger.log(`System config update completed successfully - by: ${actionByInfo}`);
return updated;
} catch (err: any) {
this.logger.error(`Error updating system config by ${actionByInfo}: ${err.message}`, err?.stack || err);
throw err;
}
}
/**
* Obtiene toda la configuración
*/
async getConfig() {
return this.prisma.systemConfig.findFirst();
}
/**
* Fuerza la generación de facturas para un mes específico
*/
async forceGenerateMonthlyInvoices(targetMonth: Date, actionBy?: AuthUser) {
const actionByInfo = actionBy ? `${actionBy.name || actionBy.id} (${actionBy.role})` : 'Unknown';
const monthStr = dayjs(targetMonth).format('YYYY-MM');
this.logger.log(`Forzando generación de facturas para ${monthStr} - actionBy: ${actionByInfo}`);
try {
const invoices = await this.invoicesService.createMonthlyInvoicesForActiveAssistants(targetMonth);
this.logger.log(`Facturas generadas exitosamente para ${monthStr} - total: ${invoices.length}, by: ${actionByInfo}`);
return {
message: `Facturas generadas exitosamente para ${monthStr}`,
totalCreated: invoices.length,
details: invoices.map(inv => ({
sessionId: inv.sessionId,
userId: inv.userId,
invoiceId: inv.id,
amount: inv.amount
}))
};
} catch (err: any) {
this.logger.error(`Error forzando generación de facturas para ${monthStr} by ${actionByInfo}: ${err.message}`, err?.stack || err);
throw err;
}
}
getFirstUrl(): string | null {
const url = this.firstUrl;
this.firstUrl = null; // consumir: solo válido una vez
return url;
}
}
+18
View File
@@ -0,0 +1,18 @@
// common/types.ts
import { UserRole } from '@prisma/client';
export type AuthUser = {
id: string;
role: UserRole;
name?: string;
customId?: string;
};
// typings/express.d.ts (asegurate que el tsconfig incluya este archivo)
declare global {
namespace Express {
interface Request {
user?: AuthUser;
}
}
}