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
+56
View File
@@ -0,0 +1,56 @@
import { Module } from '@nestjs/common';
import { WhatsappModule } from './whatsapp/whatsapp.module.js';
import { UserModule } from './user/user.module.js';
import { PrismaModule } from './prisma/prisma.module.js';
import { RfidModule } from './rfid/rfid.module';
import { AttendanceModule } from './attendance/attendance.module';
import { SessionsModule } from './sessions/sessions.module';
import { IaModule } from './ia/ia.module';
import { CustomIdModule } from './custom-id/custom-id.module';
import { OdooModule } from './odoo/odoo.module';
import { AuthModule } from './auth/auth.module';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { InvoicesModule } from './invoices/invoices.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
import { BackupModule } from './backup/backup.module';
import { SchedulingModule } from './scheduling/scheduling.module';
import { SystemConfigModule } from './common/system-config.module';
@Module({
imports: [
ThrottlerModule.forRoot({
throttlers: [
{
ttl: 60, // Tiempo en segundos
limit: 5, // Máximo 5 requests por IP
},
],
}),
PrismaModule,
WhatsappModule,
UserModule,
RfidModule,
AttendanceModule,
SessionsModule,
IaModule,
CustomIdModule,
OdooModule,
AuthModule,
InvoicesModule,
DashboardModule,
SubscriptionsModule,
BackupModule,
SchedulingModule,
SystemConfigModule,
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
]
})
export class AppModule { }
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AttendanceController } from './attendance.controller';
import { AttendanceService } from './attendance.service';
describe('AttendanceController', () => {
let controller: AttendanceController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AttendanceController],
providers: [AttendanceService],
}).compile();
controller = module.get<AttendanceController>(AttendanceController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
@@ -0,0 +1,52 @@
import { Controller, ForbiddenException, Get, Param, Put, Query, UseGuards } from '@nestjs/common';
import { UserRole } from '@prisma/client';
import { User } from '~/common/decoratos/user.decorator';
import { AuthTokenGuard } from '~/common/guards/auth-token.guard';
import { AuthUser } from '~/common/types';
import { AttendanceService } from './attendance.service';
@Controller('attendance')
@UseGuards(AuthTokenGuard)
export class AttendanceController {
constructor(private readonly attendanceService: AttendanceService) { }
@Get()
async getAccessLogsByDate(
@Query('userId') userId: string,
@Query('date') date: string,
@User() user: AuthUser,
) {
const allowed = user?.role === UserRole.ADMIN || user?.role === UserRole.INSTRUCTOR || user?.id === userId;
if (!allowed) {
throw new ForbiddenException('No tienes permisos para ver esta información');
}
if (!userId || !date) {
throw new Error('userId and date are required');
}
return await this.attendanceService.getAccessLogsByDate(userId, date);
}
@Put('/:userId/:snapshotId')
async updateAttendanceSnapshot(
@Param('userId') userId: string,
@Param('snapshotId') snapshotId: string,
@Query('unmark') unmark: string | undefined,
@User() user: AuthUser,
) {
const allowed = user?.role === UserRole.ADMIN || user?.role === UserRole.INSTRUCTOR;
if (!allowed) {
throw new ForbiddenException('No tienes permisos para actualizar asistencias');
}
const shouldUnmark = unmark === 'true';
if (shouldUnmark) {
return await this.attendanceService.unmarkAttendance(userId, snapshotId);
} else {
return await this.attendanceService.markAttendance(userId, snapshotId);
}
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AttendanceService } from './attendance.service';
import { AttendanceController } from './attendance.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { InvoicesModule } from '../invoices/invoices.module';
@Module({
controllers: [AttendanceController],
providers: [AttendanceService],
imports: [PrismaModule, InvoicesModule],
exports: [AttendanceService],
})
export class AttendanceModule {}
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AttendanceService } from './attendance.service';
describe('AttendanceService', () => {
let service: AttendanceService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AttendanceService],
}).compile();
service = module.get<AttendanceService>(AttendanceService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
@@ -0,0 +1,414 @@
import { Injectable, Logger } from '@nestjs/common';
import { AccessLog, LogDirection } from '@prisma/client';
import dayjs from 'dayjs';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AttendanceService {
private logger = new Logger(AttendanceService.name);
constructor(private prisma: PrismaService) { }
/**
* Registra un ingreso o egreso alternando según el último registro del día.
*/
async logAccess(userId: string): Promise<AccessLog> {
const startOfDay = dayjs().startOf('day').toDate();
const endOfDay = dayjs().endOf('day').toDate();
const timestamp = new Date();
// 2) Buscamos el último log de hoy
const lastLog = await this.prisma.accessLog.findFirst({
where: {
userId,
timestamp: {
gte: startOfDay,
lte: endOfDay,
},
},
orderBy: { timestamp: 'desc' },
});
// 3) Inferimos la dirección
const direction: LogDirection =
lastLog?.direction === LogDirection.INGRESS
? LogDirection.EGRESS
: LogDirection.INGRESS;
// 4) Creamos el registro
const accesLog = await this.prisma.accessLog.create({
data: { userId, direction, timestamp },
});
// 5) Si es un INGRESS, intentar marcar asistencia automáticamente
if (direction === LogDirection.INGRESS) {
await this.autoMarkAttendance(userId, timestamp);
}
return accesLog
}
/**
* Marca automáticamente la asistencia cuando un usuario hace INGRESS.
* Busca sesiones activas (como asistente o instructor) dentro de una ventana de ±1 hora.
* @param userId ID del usuario
* @param timestamp Momento del ingreso
*/
private async autoMarkAttendance(userId: string, timestamp: Date): Promise<void> {
try {
// Ventana de ±1 hora
const oneHourBefore = dayjs(timestamp).subtract(1, 'hour').toDate();
const oneHourAfter = dayjs(timestamp).add(1, 'hour').toDate();
// Buscar snapshots activos donde:
// 1. El dateRange.start <= oneHourAfter
// 2. El dateRange.end >= oneHourBefore
// 3. El usuario es asistente o instructor de la sesión
const candidateSnapshots = await this.prisma.sessionDateSnapshot.findMany({
where: {
isActive: true,
dateRange: {
start: { lte: oneHourAfter },
end: { gte: oneHourBefore }
},
session: {
OR: [
{ assistants: { some: { id: userId } } },
{ instructors: { some: { id: userId } } }
]
}
},
include: {
session: {
include: {
assistants: { where: { id: userId }, select: { id: true } },
instructors: { where: { id: userId }, select: { id: true } }
}
},
presentAssistants: { where: { id: userId }, select: { id: true } },
presentInstructors: { where: { id: userId }, select: { id: true } }
}
});
if (candidateSnapshots.length === 0) {
this.logger.log(`No active snapshots found for user ${userId} within ±1 hour window`);
return;
}
// Marcar en cada snapshot encontrado
for (const snapshot of candidateSnapshots) {
const isAlreadyMarked =
snapshot.presentAssistants.length > 0 ||
snapshot.presentInstructors.length > 0;
if (isAlreadyMarked) {
this.logger.log(`User ${userId} already marked in snapshot ${snapshot.id} - skipping`);
continue;
}
// Determinar si es instructor o asistente en esta sesión
const isInstructor = snapshot.session.instructors.length > 0;
const isAssistant = snapshot.session.assistants.length > 0;
if (isInstructor) {
await this.prisma.sessionDateSnapshot.update({
where: { id: snapshot.id },
data: {
presentInstructors: {
connect: { id: userId }
}
}
});
this.logger.log(`Auto-marked user ${userId} as instructor in snapshot ${snapshot.id}`);
} else if (isAssistant) {
await this.prisma.sessionDateSnapshot.update({
where: { id: snapshot.id },
data: {
presentAssistants: {
connect: { id: userId }
}
}
});
this.logger.log(`Auto-marked user ${userId} as assistant in snapshot ${snapshot.id}`);
}
}
} catch (error: any) {
this.logger.error(`Error in autoMarkAttendance for user ${userId}: ${error.message}`, error.stack);
// No lanzamos el error para no interrumpir el flujo del logAccess
}
}
/**
* Obtiene los últimos N registros de acceso de un usuario.
* @param userId ID del usuario
* @param limit Número máximo de registros a devolver (por defecto 5)
*/
async getLastAccesses(
userId: string,
limit = 5,
): Promise<AccessLog[]> {
return this.prisma.accessLog.findMany({
where: { userId },
orderBy: { timestamp: 'desc' },
take: limit,
});
}
async attendanceSnapshotSession(userId: string) {
const lastAccesses = await this.getLastAccesses(userId);
// Aquí puedes agregar lógica adicional para crear un snapshot de la asistencia
return lastAccesses;
}
/**
* Obtiene todos los registros de acceso de un usuario en una fecha específica
* @param userId ID del usuario
* @param date Fecha en formato ISO string
* @returns Objeto con información del usuario y sus registros de acceso
*/
async getAccessLogsByDate(userId: string, date: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true }
});
if (!user) {
throw new Error('User not found');
}
const targetDate = dayjs(date);
const startOfDay = targetDate.startOf('day').toDate();
const endOfDay = targetDate.endOf('day').toDate();
const accessLogs = await this.prisma.accessLog.findMany({
where: {
userId,
timestamp: {
gte: startOfDay,
lte: endOfDay
}
},
orderBy: { timestamp: 'asc' }
});
return {
userId: user.id,
name: user.name,
accessLogs
};
}
async updateAttendanceSnapshot(userId: string, snapShotId: string) {
try {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new Error(`User not found`);
}
const snapshot = await this.prisma.sessionDateSnapshot.findUnique({ where: { id: snapShotId } });
if (!snapshot) {
throw new Error(`Snapshot not found`);
}
const updatedSnapshot = await this.prisma.sessionDateSnapshot.update({
where: { id: snapShotId },
data: {
presentAssistants: {
connect: {
id: userId
}
}
},
});
return !!updatedSnapshot;
}
catch (error: any) {
this.logger.error(`Error updating attendance snapshot: ${error.message}`);
throw new Error(`Error updating attendance snapshot: ${error.message}`);
}
}
/**
* Marca la asistencia de un usuario en un snapshot específico
* @param userId ID del usuario
* @param snapshotId ID del snapshot de sesión
* @returns Objeto con el estado de la operación
*/
async markAttendance(userId: string, snapshotId: string) {
try {
// Verificar que el usuario existe
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, role: true }
});
if (!user) {
this.logger.warn(`Attempt to mark attendance for non-existent user: ${userId}`);
throw new Error('User not found');
}
// Verificar que el snapshot existe y obtener información de asistentes presentes
const snapshot = await this.prisma.sessionDateSnapshot.findUnique({
where: { id: snapshotId },
include: {
presentAssistants: { select: { id: true } },
presentInstructors: { select: { id: true } }
}
});
if (!snapshot) {
this.logger.warn(`Attempt to mark attendance for non-existent snapshot: ${snapshotId}`);
throw new Error('Snapshot not found');
}
// Verificar si el usuario ya está marcado como presente
const isAssistantPresent = snapshot.presentAssistants.some(a => a.id === userId);
const isInstructorPresent = snapshot.presentInstructors.some(i => i.id === userId);
const isAlreadyMarked = isAssistantPresent || isInstructorPresent;
if (isAlreadyMarked) {
this.logger.log(`User ${userId} already marked as present in snapshot ${snapshotId} - idempotent behavior`);
return {
status: 'marked',
userId,
snapshotId,
message: 'User already marked as present',
alreadyMarked: true
};
}
// Determinar si es instructor o asistente basado en el rol
if (user.role === 'INSTRUCTOR' || user.role === 'ADMIN') {
await this.prisma.sessionDateSnapshot.update({
where: { id: snapshotId },
data: {
presentInstructors: {
connect: { id: userId }
}
}
});
this.logger.log(`Instructor ${userId} marked as present in snapshot ${snapshotId}`);
} else {
await this.prisma.sessionDateSnapshot.update({
where: { id: snapshotId },
data: {
presentAssistants: {
connect: { id: userId }
}
}
});
this.logger.log(`Assistant ${userId} marked as present in snapshot ${snapshotId}`);
}
return {
status: 'marked',
userId,
snapshotId,
message: 'Attendance marked successfully',
alreadyMarked: false
};
} catch (error: any) {
this.logger.error(`Error marking attendance: ${error.message}`, error.stack);
throw error;
}
}
/**
* Desmarca la asistencia de un usuario en un snapshot específico
* @param userId ID del usuario
* @param snapshotId ID del snapshot de sesión
* @returns Objeto con el estado de la operación
*/
async unmarkAttendance(userId: string, snapshotId: string) {
try {
// Verificar que el usuario existe
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, role: true }
});
if (!user) {
this.logger.warn(`Attempt to unmark attendance for non-existent user: ${userId}`);
throw new Error('User not found');
}
// Verificar que el snapshot existe y obtener información de asistentes presentes
const snapshot = await this.prisma.sessionDateSnapshot.findUnique({
where: { id: snapshotId },
include: {
presentAssistants: { select: { id: true } },
presentInstructors: { select: { id: true } }
}
});
if (!snapshot) {
this.logger.warn(`Attempt to unmark attendance for non-existent snapshot: ${snapshotId}`);
throw new Error('Snapshot not found');
}
// Verificar si el usuario está marcado como presente
const isAssistantPresent = snapshot.presentAssistants.some(a => a.id === userId);
const isInstructorPresent = snapshot.presentInstructors.some(i => i.id === userId);
const isMarked = isAssistantPresent || isInstructorPresent;
if (!isMarked) {
this.logger.log(`User ${userId} not marked as present in snapshot ${snapshotId} - idempotent behavior`);
return {
status: 'unmarked',
userId,
snapshotId,
message: 'User was not marked as present',
wasMarked: false
};
}
// Desconectar de la relación correspondiente usando set con el array filtrado
if (isInstructorPresent) {
const remainingInstructors = snapshot.presentInstructors
.filter(i => i.id !== userId)
.map(i => ({ id: i.id }));
await this.prisma.sessionDateSnapshot.update({
where: { id: snapshotId },
data: {
presentInstructors: {
set: remainingInstructors
}
}
});
this.logger.log(`Instructor ${userId} unmarked from snapshot ${snapshotId}`);
} else {
const remainingAssistants = snapshot.presentAssistants
.filter(a => a.id !== userId)
.map(a => ({ id: a.id }));
await this.prisma.sessionDateSnapshot.update({
where: { id: snapshotId },
data: {
presentAssistants: {
set: remainingAssistants
}
}
});
this.logger.log(`Assistant ${userId} unmarked from snapshot ${snapshotId}`);
}
return {
status: 'unmarked',
userId,
snapshotId,
message: 'Attendance unmarked successfully',
wasMarked: true
};
} catch (error: any) {
this.logger.error(`Error unmarking attendance: ${error.message}`, error.stack);
throw error;
}
}
}
@@ -0,0 +1 @@
export class CreateAttendanceDto {}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateAttendanceDto } from './create-attendance.dto';
export class UpdateAttendanceDto extends PartialType(CreateAttendanceDto) {}
@@ -0,0 +1 @@
export class Attendance {}
+35
View File
@@ -0,0 +1,35 @@
import { Controller, Get, NotFoundException, Query, Res } from '@nestjs/common';
import { Response } from 'express';
import { AuthService } from './auth.service';
import { Inject, forwardRef } from '@nestjs/common';
import { WhatsappService } from '../whatsapp/whatsapp.service';
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
@Inject(forwardRef(() => WhatsappService))
private readonly whatsappService: WhatsappService,
) { }
@Get('verify')
verificar(@Query('t') token: string) {
return this.authService.verificarToken(token);
}
@Get('getToken')
async getToken() {
if (process.env.NODE_ENV === 'production') {
throw new NotFoundException();
}
return await this.authService.getTokenDev();
}
@Get('init')
async redirectDynamic(@Res() res: Response) {
const whatsappStatus = await this.whatsappService.status().catch(() => null);
return await this.authService.init(res, whatsappStatus?.status ?? null);
}
}
//650030
+33
View File
@@ -0,0 +1,33 @@
import { forwardRef, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PrismaModule } from '../prisma/prisma.module';
import { SystemConfigModule } from '../common/system-config.module';
import { SystemConfigService } from '../common/system-config.service';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { WhatsappModule } from '../whatsapp/whatsapp.module';
@Module({
controllers: [AuthController],
providers: [AuthService],
imports: [
JwtModule.registerAsync({
imports: [SystemConfigModule],
useFactory: async (systemConfig: SystemConfigService) => {
// Esperar a que se cargue la configuración
await new Promise(resolve => setTimeout(resolve, 100));
const expirationMinutes = systemConfig.getTokenExpirationMinutes();
return {
secret: process.env.SECRET_JWT,
signOptions: { expiresIn: `${expirationMinutes}m` },
};
},
inject: [SystemConfigService],
}),
PrismaModule,
SystemConfigModule,
forwardRef(() => WhatsappModule),
],
exports: [AuthService],
})
export class AuthModule { }
+142
View File
@@ -0,0 +1,142 @@
import { forwardRef, Inject, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { User } from '@prisma/client';
import { customAlphabet } from 'nanoid';
import { PrismaService } from '../prisma/prisma.service';
import { SystemConfigService } from '../common/system-config.service';
import { Response } from 'express';
export enum TokenRedirectURL {
FRONT = 'FRONT',
SESSION = 'SESSION',
USER = 'USER',
WHATSAPP = 'WHATSAPP',
CONFIG = 'CONFIG',
}
const RedirectToUrlParams = {
[TokenRedirectURL.FRONT]: '/',
[TokenRedirectURL.SESSION]: 'clases',
[TokenRedirectURL.USER]: 'usuarios',
[TokenRedirectURL.WHATSAPP]: 'whatsapp',
[TokenRedirectURL.CONFIG]: 'configuracion/sistema',
};
@Injectable()
export class AuthService {
private readonly nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 10);
private readonly logger = new Logger(AuthService.name);
constructor(
private prisma: PrismaService,
@Inject(forwardRef(() => SystemConfigService))
private systemConfig: SystemConfigService
) { }
async getTokenDev() {
try {
const user = await this.prisma.user.findFirst({
where: {
role: 'ADMIN',
phone: { not: null }
}
});
return await this.generarToken(user?.id, TokenRedirectURL.SESSION);
}
catch (error: any) {
}
}
getLoginLink(user: User): Promise<string> {
return this.generarToken(user.id).then(token => `${process.env.URL_FRONT}?t=${token}`);
}
//NOTE duration opcional para el primer arranque , MINUTOS
async generarToken(userId?: string, redirectUrl?: TokenRedirectURL, duration?: number): Promise<string> {
try {
const now = Date.now();
// Buscar token activo existente
const existing = userId ? await this.prisma.token.findFirst({
where: {
userId,
expiresAt: { gt: new Date(now) },
isActive: { equals: true }
},
})
: false
if (existing) {
return existing.token;
}
// Si no hay uno válido, crear uno nuevo
const token = this.nanoid();
const expirationMinutes = this.systemConfig.getTokenExpirationMinutes();
const expiresAt = new Date(now + (duration ?? expirationMinutes) * 60 * 1000);
await this.prisma.token.create({
data: {
token,
userId,
expiresAt,
...(redirectUrl
? { redirectUrl: RedirectToUrlParams[redirectUrl] }
: { redirectUrl: RedirectToUrlParams[TokenRedirectURL.FRONT] })
},
});
return token;
}
catch (error: any) {
this.logger.error('Error generating token', error);
throw new InternalServerErrorException('Error generating token');
}
}
async verificarToken(token: string): Promise<{ autorizado: boolean; user?: User, redirectUrl?: string }> {
try {
const found = await this.prisma.token.findUnique({
where: { token, isActive: { equals: true } },
include: { user: true },
});
if (!found) return { autorizado: false };
const expired = new Date() > found.expiresAt;
if (expired) return { autorizado: false };
await this.prisma.token.update({ where: { id: found.id }, data: { isActive: false } });
return { autorizado: true, user: found?.user || undefined, redirectUrl: found.redirectUrl! };
} catch {
return { autorizado: false };
}
}
async init(res: Response, whatsappStatus?: 'Correcto' | 'Sin session' | null) {
try {
// Caso 1: primera vez que arranca el sistema (admin creado, token listo)
const firstUrl = this.systemConfig.getFirstUrl();
if (firstUrl) {
return res.redirect(302, firstUrl);
}
// Caso 2: WhatsApp sin sesión → redirigir al front en la sección de WhatsApp
if (whatsappStatus === 'Sin session') {
const admin = await this.prisma.user.findFirst({ where: { role: 'ADMIN' } });
const token = await this.generarToken(admin?.id, TokenRedirectURL.WHATSAPP);
return res.redirect(302, `${process.env.URL_FRONT}?t=${token}`);
}
// Caso 3: sistema normal no dar acceso
return res.sendStatus(403);
} catch (error: any) {
this.logger.error('Error in init', error);
return res.sendStatus(500);
}
}
}
+254
View File
@@ -0,0 +1,254 @@
import {
Controller,
Get,
Post,
Res,
UseGuards,
ForbiddenException,
InternalServerErrorException,
Logger,
UseInterceptors,
UploadedFile,
BadRequestException
} from '@nestjs/common';
import { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import * as path from 'path';
import { BackupService } from './backup.service';
import { AuthTokenGuard } from '~/common/guards/auth-token.guard';
import { User } from '~/common/decoratos/user.decorator';
import { AuthUser } from '~/common/types';
import { UserRole } from '@prisma/client';
import * as fs from 'fs';
import { AuthService, TokenRedirectURL } from '~/auth/auth.service';
@Controller('backup')
@UseGuards(AuthTokenGuard)
export class BackupController {
private readonly logger = new Logger(BackupController.name);
constructor(
private readonly backupService: BackupService,
private readonly authService: AuthService
) { }
@Get('download')
async downloadBackup(
@User() user: AuthUser,
@Res() res: Response,
): Promise<void> {
// Solo administradores pueden descargar backups
if (user?.role !== UserRole.ADMIN) {
throw new ForbiddenException('Solo los administradores pueden descargar backups');
}
let backupInfo: { filePath: string; fileName: string } | null = null;
try {
this.logger.log(`Admin ${user.id} solicitó backup de la base de datos`);
// Limpiar backups antiguos antes de crear uno nuevo
await this.backupService.cleanupOldBackups();
// Crear el backup
backupInfo = await this.backupService.createBackup();
// Verificar que el archivo existe
if (!fs.existsSync(backupInfo.filePath)) {
throw new Error('El archivo de backup no se encontró');
}
const stats = fs.statSync(backupInfo.filePath);
// Configurar headers para la descarga
res.setHeader('Content-Type', 'application/sql');
res.setHeader('Content-Disposition', `attachment; filename="${backupInfo.fileName}"`);
res.setHeader('Content-Length', stats.size);
// Crear stream para enviar el archivo
const fileStream = fs.createReadStream(backupInfo.filePath);
// Manejar errores del stream
fileStream.on('error', (error) => {
this.logger.error('Error leyendo archivo de backup:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Error al leer el archivo de backup' });
}
});
// Enviar el archivo
fileStream.pipe(res);
// Limpiar el archivo después de enviarlo
fileStream.on('end', () => {
if (backupInfo) {
this.logger.log(`Backup enviado exitosamente: ${backupInfo.fileName}`);
// Eliminar el archivo después de un pequeño delay para asegurar que se envió completamente
setTimeout(() => {
this.backupService.deleteBackupFile(backupInfo!.filePath);
}, 1000);
}
});
} catch (error: any) {
this.logger.error('Error generando o enviando backup:', error.message);
// Limpiar archivo si se creó pero hubo error
if (backupInfo?.filePath) {
this.backupService.deleteBackupFile(backupInfo.filePath);
}
if (!res.headersSent) {
throw new InternalServerErrorException('Error al generar el backup de la base de datos');
}
}
}
@Post('upload')
@UseInterceptors(
FileInterceptor('backup', {
storage: diskStorage({
destination: (req, file, cb) => {
const uploadPath = path.join(process.cwd(), 'tmp');
cb(null, uploadPath);
},
filename: (req, file, cb) => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `restore-${timestamp}-${file.originalname}`;
cb(null, filename);
},
}),
fileFilter: (req, file, cb) => {
// Solo permitir archivos .sql
if (file.mimetype === 'application/sql' ||
file.originalname.endsWith('.sql') ||
file.mimetype === 'text/plain') {
cb(null, true);
} else {
cb(new BadRequestException('Solo se permiten archivos .sql'), false);
}
},
limits: {
fileSize: 100 * 1024 * 1024, // 100MB máximo
},
})
)
async uploadBackup(
@UploadedFile() file: Express.Multer.File,
@User() user: AuthUser,
) {
// Solo administradores pueden restaurar backups
if (user?.role && user?.role !== UserRole.ADMIN) {
throw new ForbiddenException('Solo los administradores pueden restaurar backups');
}
if (!file) {
throw new BadRequestException('No se proporcionó ningún archivo');
}
try {
this.logger.log(`Admin ${user?.id} subió backup: ${file.originalname} (${file.size} bytes)`);
const result = await this.backupService.restoreBackup(file.path, file.originalname);
// El usuario actual puede no existir en el backup restaurado (si el backup es de un
// estado anterior)
let urlCallback = `${process.env.BACKEND_URL ?? ''}/auth/init`;
try {
const token = await this.authService.generarToken(user?.id, TokenRedirectURL.FRONT);
urlCallback = `${process.env.URL_FRONT}?t=${token}`;
} catch {
this.logger.warn('El usuario actual no existe en el backup restaurado. Redirigiendo a /auth/init.');
}
return {
success: true,
message: 'Backup restaurado exitosamente',
urlCallback,
details: result,
timestamp: new Date().toISOString(),
};
} catch (error: any) {
this.logger.error('Error restaurando backup:', error.message);
// Limpiar archivo en caso de error
this.backupService.deleteBackupFile(file.path);
throw new InternalServerErrorException('Error al restaurar el backup');
}
}
@Get('status')
async getBackupStatus(@User() user: AuthUser) {
// Solo administradores pueden ver el estado
if (user?.role !== UserRole.ADMIN) {
throw new ForbiddenException('Solo los administradores pueden ver el estado de backups');
}
return {
message: 'Servicio de backup disponible',
timestamp: new Date().toISOString(),
databaseUrl: process.env.DATABASE_URL ? 'Configurado' : 'No configurado',
};
}
@Post('validate')
@UseInterceptors(
FileInterceptor('backup', {
storage: diskStorage({
destination: (req, file, cb) => {
const uploadPath = path.join(process.cwd(), 'tmp');
cb(null, uploadPath);
},
filename: (req, file, cb) => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `validate-${timestamp}-${file.originalname}`;
cb(null, filename);
},
}),
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/sql' ||
file.originalname.endsWith('.sql') ||
file.mimetype === 'text/plain') {
cb(null, true);
} else {
cb(new BadRequestException('Solo se permiten archivos .sql'), false);
}
},
limits: {
fileSize: 100 * 1024 * 1024, // 100MB máximo
},
})
)
async validateBackupFile(
@UploadedFile() file: Express.Multer.File,
@User() user: AuthUser,
) {
// Solo administradores pueden validar backups
if (user?.role !== UserRole.ADMIN) {
throw new ForbiddenException('Solo los administradores pueden validar backups');
}
if (!file) {
throw new BadRequestException('No se proporcionó ningún archivo');
}
try {
const isValid = await this.backupService.validateBackupFile(file.path);
return {
valid: isValid.valid,
message: isValid.message,
details: isValid.details,
};
} catch (error: any) {
throw new BadRequestException('Error al validar el archivo');
} finally {
// Siempre limpiar el archivo de validación
this.backupService.deleteBackupFile(file.path);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { BackupController } from './backup.controller';
import { BackupService } from './backup.service';
import { AuthModule } from '~/auth/auth.module';
@Module({
imports: [PrismaModule, AuthModule],
controllers: [BackupController],
providers: [BackupService],
exports: [BackupService],
})
export class BackupModule { }
+415
View File
@@ -0,0 +1,415 @@
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import * as path from 'path';
import { PrismaService } from '../prisma/prisma.service';
const execAsync = promisify(exec);
@Injectable()
export class BackupService {
private readonly logger = new Logger(BackupService.name);
constructor(private readonly prisma: PrismaService) {}
private cleanDatabaseUrl(databaseUrl: string): string {
try {
const url = new URL(databaseUrl);
// Remover parámetros de query que pg_dump no reconoce
url.search = '';
return url.toString();
} catch (error) {
// Si no es una URL válida, intentar limpiar manualmente
return databaseUrl.split('?')[0];
}
}
async createBackup(): Promise<{ filePath: string; fileName: string }> {
try {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL no está configurada');
}
// Limpiar la URL de parámetros que pg_dump no reconoce
const cleanDatabaseUrl = this.cleanDatabaseUrl(databaseUrl);
// Generar nombre único para el archivo
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const fileName = `backup-${timestamp}.sql`;
const backupDir = path.join(process.cwd(), 'tmp');
const filePath = path.join(backupDir, fileName);
// Crear directorio tmp si no existe
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
// Ejecutar pg_dump (instalado en el contenedor)
this.logger.log('Iniciando backup de la base de datos...');
const command = `pg_dump "${cleanDatabaseUrl}" -f "${filePath}"`;
await execAsync(command);
// Verificar que el archivo se creó correctamente
if (!fs.existsSync(filePath)) {
throw new Error('El archivo de backup no se creó correctamente');
}
const stats = fs.statSync(filePath);
this.logger.log(`Backup creado exitosamente: ${fileName} (${stats.size} bytes)`);
return { filePath, fileName };
} catch (error: any) {
this.logger.error('Error creando backup:', error.message);
throw new InternalServerErrorException('Error al crear el backup de la base de datos');
}
}
async restoreBackup(filePath: string, originalName: string): Promise<{ tablesRestored: string; fileSize: number }> {
try {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL no está configurada');
}
// Limpiar la URL de parámetros que psql no reconoce
const cleanDatabaseUrl = this.cleanDatabaseUrl(databaseUrl);
// Verificar que el archivo existe
if (!fs.existsSync(filePath)) {
throw new Error('Archivo de backup no encontrado');
}
const stats = fs.statSync(filePath);
this.logger.log(`Iniciando restauración desde: ${originalName} (${stats.size} bytes)`);
// DEBUG: Analizar contenido del backup
await this.debugBackupContent(filePath);
// **IMPORTANTE: Esto eliminará TODOS los datos actuales y desconectará la aplicación temporalmente**
this.logger.log('Eliminando base de datos completa y recreando desde cero...');
// Paso 1: Obtener el nombre de la base de datos de la URL
const dbUrl = new URL(cleanDatabaseUrl);
const dbName = dbUrl.pathname.substring(1); // Remover el "/"
// Paso 2: Conectar a postgres (base de datos administrativa) para eliminar y recrear
const adminUrl = cleanDatabaseUrl.replace(`/${dbName}`, '/postgres');
// Paso 3: Terminar todas las conexiones activas a la base de datos (incluyendo la aplicación)
this.logger.log(`⚠️ Terminando TODAS las conexiones a la base de datos ${dbName}...`);
const terminateConnectionsCommand = `psql "${adminUrl}" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${dbName}' AND pid <> pg_backend_pid();"`;
try {
const { stdout: terminated } = await execAsync(terminateConnectionsCommand);
this.logger.log('Conexiones terminadas:', terminated.trim());
} catch (error: any) {
this.logger.warn('Error terminando conexiones (puede ser normal):', error.message);
}
// Paso 4: Esperar a que las conexiones se cierren
this.logger.log('Esperando que las conexiones se cierren completamente...');
await new Promise(resolve => setTimeout(resolve, 2000));
// Paso 5: Eliminar la base de datos actual
this.logger.log(`🗑️ Eliminando base de datos ${dbName}...`);
const dropCommand = `psql "${adminUrl}" -c "DROP DATABASE IF EXISTS \"${dbName}\";"`;
await execAsync(dropCommand);
// Paso 6: Crear la base de datos nuevamente (vacía)
this.logger.log(`🔨 Creando base de datos ${dbName} limpia...`);
const createCommand = `psql "${adminUrl}" -c "CREATE DATABASE \"${dbName}\";"`;
await execAsync(createCommand);
// Paso 7: Restaurar el backup completo en la base de datos limpia
this.logger.log('📦 Restaurando backup completo en la nueva base de datos...');
const restoreCommand = `psql "${cleanDatabaseUrl}" -f "${filePath}"`;
const { stdout, stderr } = await execAsync(restoreCommand);
// Paso 8: Verificar que TODAS las tablas fueron restauradas correctamente
this.logger.log('Verificando que todas las tablas del schema fueron restauradas...');
const verifyCommand = `psql "${cleanDatabaseUrl}" -c "
SELECT 'User' as tabla, COUNT(*) as registros FROM \\"User\\"
UNION ALL SELECT 'AccessLog', COUNT(*) FROM \\"AccessLog\\"
UNION ALL SELECT 'Token', COUNT(*) FROM \\"Token\\"
UNION ALL SELECT 'Session', COUNT(*) FROM \\"Session\\"
UNION ALL SELECT 'SessionPriceHistory', COUNT(*) FROM \\"SessionPriceHistory\\"
UNION ALL SELECT 'SessionDateRange', COUNT(*) FROM \\"SessionDateRange\\"
UNION ALL SELECT 'SessionDateSnapshot', COUNT(*) FROM \\"SessionDateSnapshot\\"
UNION ALL SELECT 'Invoice', COUNT(*) FROM \\"Invoice\\"
UNION ALL SELECT 'SystemConfig', COUNT(*) FROM \\"SystemConfig\\"
ORDER BY tabla;
"`;
try {
const { stdout: verifyOutput } = await execAsync(verifyCommand);
this.logger.log('✅ Verificación completa de todas las tablas:');
this.logger.log(verifyOutput.trim());
} catch (error: any) {
this.logger.error('❌ Error en verificación de tablas:', error.message);
throw new Error('La restauración no incluyó todas las tablas esperadas');
}
// Paso 9: Verificar integridad de relaciones many-to-many
this.logger.log('Verificando tablas de relación many-to-many...');
const verifyRelationsCommand = `psql "${cleanDatabaseUrl}" -c "
SELECT
'_InstructorSessions' as relacion, COUNT(*) as registros
FROM \\"_InstructorSessions\\"
UNION ALL
SELECT '_AssistantSessions', COUNT(*)
FROM \\"_AssistantSessions\\"
UNION ALL
SELECT '_PresentInstructorsOnDate', COUNT(*)
FROM \\"_PresentInstructorsOnDate\\"
UNION ALL
SELECT '_PresentAssistantsOnDate', COUNT(*)
FROM \\"_PresentAssistantsOnDate\\"
UNION ALL
SELECT '_SubstituteInstructorsOnDate', COUNT(*)
FROM \\"_SubstituteInstructorsOnDate\\";
"`;
try {
const { stdout: relationsOutput } = await execAsync(verifyRelationsCommand);
this.logger.log('✅ Verificación de relaciones many-to-many:');
this.logger.log(relationsOutput.trim());
} catch (error: any) {
this.logger.warn('⚠️ Error verificando relaciones (puede ser normal si están vacías):', error.message);
}
// Extraer información del resultado
const output = stdout + stderr;
// Contar las operaciones procesadas con más detalle
const createTableMatches = output.match(/CREATE TABLE/g) || [];
const copyMatches = output.match(/COPY \d+/g) || [];
const createIndexMatches = output.match(/CREATE INDEX/g) || [];
const alterTableMatches = output.match(/ALTER TABLE/g) || [];
this.logger.log(`📊 Resumen de operaciones completadas:`);
this.logger.log(`- Tablas creadas: ${createTableMatches.length}`);
this.logger.log(`- Operaciones COPY (datos): ${copyMatches.length}`);
this.logger.log(`- Índices creados: ${createIndexMatches.length}`);
this.logger.log(`- Constraints/FKs: ${alterTableMatches.length}`);
// Verificar que tenemos al menos las 9 tablas principales + 5 tablas de relación
const expectedMinTables = 14; // 9 modelos + 5 relaciones many-to-many
if (createTableMatches.length < expectedMinTables) {
this.logger.warn(`⚠️ Se esperaban al menos ${expectedMinTables} tablas, se encontraron ${createTableMatches.length}`);
}
if (output.includes('ERROR') || stderr.includes('ERROR')) {
this.logger.error('❌ Se encontraron errores en la restauración:', stderr);
throw new Error('La restauración completó con errores');
}
// Reconectar Prisma: el pool quedó roto porque se terminaron todas las
// conexiones durante el DROP/CREATE DATABASE
this.logger.log('🔌 Reconectando Prisma al nuevo estado de la base de datos...');
await this.prisma.$disconnect();
await this.prisma.$connect();
this.logger.log('✅ Prisma reconectado exitosamente');
// Limpiar archivo después de restaurar
setTimeout(() => {
this.deleteBackupFile(filePath);
}, 1000);
return {
tablesRestored: `${createTableMatches.length} tablas, ${copyMatches.length} operaciones de datos`,
fileSize: stats.size,
};
} catch (error: any) {
this.logger.error('Error restaurando backup:', error.message);
// Limpiar archivo en caso de error
this.deleteBackupFile(filePath);
throw new InternalServerErrorException('Error al restaurar el backup de la base de datos');
}
}
async validateBackupFile(filePath: string): Promise<{ valid: boolean; message: string; details?: any }> {
try {
if (!fs.existsSync(filePath)) {
return { valid: false, message: 'Archivo no encontrado' };
}
const stats = fs.statSync(filePath);
if (stats.size === 0) {
return { valid: false, message: 'El archivo está vacío' };
}
// Leer las primeras líneas del archivo para validar el formato
const fileContent = fs.readFileSync(filePath, 'utf8');
const lines = fileContent.split('\n').slice(0, 20); // Primeras 20 líneas
// Verificar que parece un dump de PostgreSQL
const hasSQLHeader = lines.some(line =>
line.includes('PostgreSQL database dump') ||
line.includes('pg_dump') ||
line.startsWith('--') ||
line.includes('CREATE TABLE') ||
line.includes('INSERT INTO') ||
line.includes('COPY ')
);
if (!hasSQLHeader) {
return {
valid: false,
message: 'El archivo no parece ser un backup válido de PostgreSQL'
};
}
return {
valid: true,
message: 'Archivo válido',
details: {
size: stats.size,
sizeFormatted: this.formatBytes(stats.size),
lines: fileContent.split('\n').length,
}
};
} catch (error: any) {
this.logger.error('Error validando archivo:', error.message);
return {
valid: false,
message: 'Error al procesar el archivo'
};
}
}
private formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async cleanupOldBackups(maxAge: number = 24 * 60 * 60 * 1000): Promise<void> {
try {
const backupDir = path.join(process.cwd(), 'tmp');
if (!fs.existsSync(backupDir)) {
return;
}
const files = fs.readdirSync(backupDir);
const now = Date.now();
for (const file of files) {
if (file.startsWith('backup-') && file.endsWith('.sql')) {
const filePath = path.join(backupDir, file);
const stats = fs.statSync(filePath);
if (now - stats.mtime.getTime() > maxAge) {
fs.unlinkSync(filePath);
this.logger.log(`Backup antiguo eliminado: ${file}`);
}
}
}
} catch (error: any) {
this.logger.warn('Error limpiando backups antiguos:', error.message);
}
}
async debugBackupContent(filePath: string): Promise<void> {
try {
this.logger.log('=== DEBUG: Analizando contenido del backup ===');
// Verificar todas las tablas principales del schema
const tables = [
'User', 'AccessLog', 'Token', 'Session',
'SessionPriceHistory', 'SessionDateRange', 'SessionDateSnapshot',
'Invoice', 'SystemConfig'
];
for (const table of tables) {
const grepCommand = `grep -c 'COPY public."${table}"' "${filePath}" || echo "0"`;
try {
const { stdout } = await execAsync(grepCommand);
const count = parseInt(stdout.trim());
if (count > 0) {
this.logger.log(`✅ Tabla "${table}" encontrada en backup`);
} else {
this.logger.warn(`⚠️ Tabla "${table}" NO encontrada en backup`);
}
} catch (error: any) {
this.logger.warn(`❌ Error verificando tabla "${table}":`, error.message);
}
}
// Verificar tablas de relación many-to-many
const relationTables = [
'_InstructorSessions', '_AssistantSessions',
'_PresentInstructorsOnDate', '_PresentAssistantsOnDate',
'_SubstituteInstructorsOnDate'
];
this.logger.log('--- Verificando tablas de relación ---');
for (const table of relationTables) {
const grepCommand = `grep -c 'COPY public."${table}"' "${filePath}" || echo "0"`;
try {
const { stdout } = await execAsync(grepCommand);
const count = parseInt(stdout.trim());
if (count > 0) {
this.logger.log(`✅ Relación "${table}" encontrada en backup`);
} else {
this.logger.log(`️ Relación "${table}" vacía o no encontrada (normal si no hay datos)`);
}
} catch (error: any) {
this.logger.warn(`Error verificando relación "${table}":`, error.message);
}
}
// Contar líneas totales
const wcCommand = `wc -l "${filePath}"`;
const { stdout: lineCount } = await execAsync(wcCommand);
this.logger.log(`📄 Total de líneas en backup: ${lineCount.trim()}`);
// Verificar que tiene la estructura completa de PostgreSQL dump
const structureChecks = [
{ pattern: 'PostgreSQL database dump', name: 'Header de PostgreSQL' },
{ pattern: 'CREATE TABLE', name: 'Definiciones de tablas' },
{ pattern: 'CREATE INDEX', name: 'Índices' },
{ pattern: 'ALTER TABLE.*ADD CONSTRAINT', name: 'Foreign Keys' },
{ pattern: 'CREATE TYPE', name: 'Enums (tipos personalizados)' },
];
for (const check of structureChecks) {
const grepCommand = `grep -c '${check.pattern}' "${filePath}" || echo "0"`;
try {
const { stdout } = await execAsync(grepCommand);
const count = parseInt(stdout.trim());
this.logger.log(`${count > 0 ? '✅' : '⚠️ '} ${check.name}: ${count} encontrados`);
} catch (error: any) {
this.logger.warn(`Error verificando ${check.name}`);
}
}
this.logger.log('=== FIN DEBUG ===');
} catch (error: any) {
this.logger.error('Error en debug:', error.message);
}
}
deleteBackupFile(filePath: string): void {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
this.logger.log(`Archivo de backup eliminado: ${path.basename(filePath)}`);
}
} catch (error: any) {
this.logger.warn('Error eliminando archivo de backup:', error.message);
}
}
}
@@ -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;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CustomIdService } from './custom-id.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
providers: [CustomIdService],
imports: [PrismaModule],
exports: [CustomIdService],
})
export class CustomIdModule { }
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CustomIdService } from './custom-id.service';
describe('CustomIdService', () => {
let service: CustomIdService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CustomIdService],
}).compile();
service = module.get<CustomIdService>(CustomIdService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
@@ -0,0 +1,40 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class CustomIdService {
private readonly logger = new Logger(CustomIdService.name);
constructor(private readonly prisma: PrismaService) { }
async generateCustomId(modelName: keyof PrismaClient, prefix: string): Promise<string> {
this.logger.log(`Creando nuevo Custom ID para modelo: ${String(modelName)}`);
try {
const model = (this.prisma as any)?.[modelName];
const lastRecord = await model.findFirst({
where: { customId: { startsWith: `${prefix}-` } },
orderBy: { customId: 'desc' },
});
let nextNumber = 1;
if (lastRecord?.customId) {
const lastNumber = parseInt(lastRecord.customId.slice(prefix.length + 1), 36);
nextNumber = lastNumber + 1;
}
const nextIdPart = nextNumber.toString(36).padStart(3, '0').toUpperCase();
const customId = `${prefix}-${nextIdPart}`.toLocaleUpperCase();
this.logger.log(`Custom ID generado: ${customId}`);
return customId;
} catch (error: any) {
this.logger.error(
`Error al generar Custom ID para modelo: ${String(modelName)}`,
error.message
);
throw new Error('No se pudo generar el Custom ID.');
}
}
}
@@ -0,0 +1 @@
export class CreateCustomIdDto {}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateCustomIdDto } from './create-custom-id.dto';
export class UpdateCustomIdDto extends PartialType(CreateCustomIdDto) {}
@@ -0,0 +1 @@
export class CustomId {}
@@ -0,0 +1,22 @@
import { Controller, ForbiddenException, Get, UseGuards } from '@nestjs/common';
import { DashboardService } from './dashboard.service';
import { AuthTokenGuard } from '../common/guards/auth-token.guard';
import { AuthUser } from '../common/types';
import { DashboardUser } from './dto/dashboard.dto';
import { User } from '../common/decoratos/user.decorator';
@UseGuards(AuthTokenGuard)
@Controller('dashboard')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) { }
@Get()
async getDashboard(@User() user: AuthUser): Promise<DashboardUser> {
const allowed = user?.role === 'ADMIN' || user?.role === 'INSTRUCTOR';
if (!allowed) {
throw new ForbiddenException('No tienes permisos para ver el dashboard');
}
return this.dashboardService.getDashboard(user);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DashboardService } from './dashboard.service';
import { DashboardController } from './dashboard.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
controllers: [DashboardController],
providers: [DashboardService],
imports: [PrismaModule],
})
export class DashboardModule { }
+182
View File
@@ -0,0 +1,182 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DashboardUser } from './dto/dashboard.dto';
import { AuthUser } from '../common/types';
import { LogDirection, UserRole } from '@prisma/client';
import dayjs from 'dayjs';
@Injectable()
export class DashboardService {
constructor(private prisma: PrismaService) { }
async getDashboard(user: AuthUser): Promise<DashboardUser> {
const now = dayjs().startOf('day').toDate() //new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
//NOTE Primer INGRESS de hoy (inicio del día)
const currentAccess = await this.prisma.accessLog.findFirst({
where: {
userId: user.id,
direction: LogDirection.INGRESS,
timestamp: { gte: start, lte: end },
},
orderBy: { timestamp: 'asc' },
});
const previousIngress = await this.prisma.accessLog.findFirst({
where: {
userId: user.id,
direction: LogDirection.INGRESS,
timestamp: currentAccess
? { lt: currentAccess.timestamp } // anterior al primer INGRESS de hoy
: { lt: start }, // si hoy no hubo, el último antes de hoy
},
orderBy: { timestamp: 'desc' }, // el más reciente anterior
});
const nextSessions = await this.prisma.sessionDateSnapshot.findMany({
where: {
session: { isActive: true },
AND: {
dateRange: { start: { gte: now } },
session: {
...(user.role === UserRole.INSTRUCTOR ? { instructors: { some: { id: user.id } } } : {}),
}
}
},
orderBy: { dateRange: { start: 'asc' } },
take: 10,
select: {
id: true,
dateRange: { select: { start: true } },
session: {
select: {
id: true,
description: true,
instructors: { select: { name: true } },
},
},
},
});
// Calcular sesiones activas e inactivas
const sessionsFilter = user.role === UserRole.INSTRUCTOR
? { instructors: { some: { id: user.id } } }
: {};
const [activeSessions, inactiveSessions] = await Promise.all([
this.prisma.session.count({
where: { isActive: true, ...sessionsFilter }
}),
this.prisma.session.count({
where: { isActive: false, ...sessionsFilter }
})
]);
// Calcular usuarios activos e inactivos (solo para admin)
let usersData = { total: 0, active: 0, inactive: 0 };
if (user.role !== UserRole.INSTRUCTOR) {
const [activeUsers, inactiveUsers] = await Promise.all([
this.prisma.user.count({ where: { deleted: null } }),
this.prisma.user.count({ where: { deleted: { not: null } } })
]);
usersData = {
total: activeUsers + inactiveUsers,
active: activeUsers,
inactive: inactiveUsers
};
}
// Contar asistentes en sesiones activas
const assistantsCount = user.role === UserRole.INSTRUCTOR
? await this.prisma.user.count({
where: {
assistantSessions: {
some: {
isActive: true,
instructors: { some: { id: user.id } }
}
}
}
})
: await this.prisma.user.count({
where: {
assistantSessions: {
some: { isActive: true }
}
}
});
// Calcular facturas del mes actual: total, pagadas y adeudadas
const startOfMonth = dayjs().startOf('month').toDate();
const endOfMonth = dayjs().endOf('month').toDate();
const [totalInvoices, paidInvoices, pendingInvoices] = await Promise.all([
// Total de facturas del mes
this.prisma.invoice.count({
where: {
dateInvoice: {
gte: startOfMonth,
lte: endOfMonth
}
}
}),
// Facturas pagadas (PAID o CANCELED)
this.prisma.invoice.count({
where: {
dateInvoice: {
gte: startOfMonth,
lte: endOfMonth
},
status: {
in: ['PAID', 'CANCELED']
}
}
}),
// Facturas adeudadas (PENDING)
this.prisma.invoice.count({
where: {
dateInvoice: {
gte: startOfMonth,
lte: endOfMonth
},
status: 'PENDING'
}
})
]);
const reponse: DashboardUser = {
message: 'OK',
accessLog: {
currentAccess: currentAccess?.timestamp ? currentAccess.timestamp.toISOString() : 'Sin registro',
lastAccess: previousIngress?.timestamp ? previousIngress.timestamp.toISOString() : 'Sin registro',
},
cardDetails: {
sessions: {
total: activeSessions + inactiveSessions,
active: activeSessions,
inactive: inactiveSessions
},
users: usersData,
assistants: assistantsCount,
invoices: {
total: totalInvoices,
paid: paidInvoices,
pending: pendingInvoices
},
revenue: 0, // TODO: sumarizar desde tu tabla de pagos/suscripciones
},
sessions: nextSessions.map(snapshot => ({
id: snapshot.id,
description: snapshot.session.description,
instructors: snapshot.session.instructors.map(i => i.name).join(', '),
startDate: snapshot.dateRange.start.toISOString(),
})),
};
return reponse;
}
}
@@ -0,0 +1 @@
export class CreateDashboardDto {}
@@ -0,0 +1,32 @@
export type DashboardUser = {
message: string;
accessLog: {
currentAccess: string;
lastAccess: string;
},
cardDetails: {
sessions: {
total: number;
active: number;
inactive: number;
};
users: {
total: number;
active: number;
inactive: number;
};
assistants: number; // total de usuarios inscriptos a clases activas
invoices: {
total: number; // total de facturas del mes actual
paid: number; // facturas pagadas (PAID o CANCELED)
pending: number; // facturas adeudadas (PENDING)
};
revenue: number;
},
sessions: {
id: string;
description: string;
instructors: string;
startDate: string;
}[]
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateDashboardDto } from './create-dashboard.dto';
export class UpdateDashboardDto extends PartialType(CreateDashboardDto) {}
@@ -0,0 +1 @@
export class Dashboard {}
+1
View File
@@ -0,0 +1 @@
export class CreateIaDto {}
+4
View File
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateIaDto } from './create-ia.dto';
export class UpdateIaDto extends PartialType(CreateIaDto) {}
+1
View File
@@ -0,0 +1 @@
export class Ia {}
+20
View File
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { IaController } from './ia.controller';
import { IaService } from './ia.service';
describe('IaController', () => {
let controller: IaController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [IaController],
providers: [IaService],
}).compile();
controller = module.get<IaController>(IaController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
// import { IaService } from './ia.service';
import { CreateIaDto } from './dto/create-ia.dto';
import { UpdateIaDto } from './dto/update-ia.dto';
@Controller('ia')
export class IaController {
constructor() {}
// @Post()
// create(@Body() createIaDto: CreateIaDto) {
// return this.iaService.create(createIaDto);
// }
// @Get()
// findAll() {
// return this.iaService.findAll();
// }
// @Get(':id')
// findOne(@Param('id') id: string) {
// return this.iaService.findOne(+id);
// }
// @Patch(':id')
// update(@Param('id') id: string, @Body() updateIaDto: UpdateIaDto) {
// return this.iaService.update(+id, updateIaDto);
// }
// @Delete(':id')
// remove(@Param('id') id: string) {
// return this.iaService.remove(+id);
// }
}
+18
View File
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
// import { IaService } from './ia.service';
import { IaController } from './ia.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { HttpModule } from '@nestjs/axios';
import { IaSubscriptionService } from './services/ia.subscription.service';
@Module({
controllers: [IaController],
providers: [
// // IaService,
IaSubscriptionService],
imports: [PrismaModule, HttpModule],
exports: [
// // IaService,
IaSubscriptionService],
})
export class IaModule { }
+18
View File
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { IaService } from './ia.service';
describe('IaService', () => {
let service: IaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [IaService],
}).compile();
service = module.get<IaService>(IaService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
+498
View File
@@ -0,0 +1,498 @@
// import { get_encoding, Tiktoken } from '@dqbd/tiktoken';
// import { HttpService } from '@nestjs/axios';
// import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
// import { Prisma, UserRole } from '@prisma/client';
// import dayjs from 'dayjs';
// import { firstValueFrom } from 'rxjs';
// import { PrismaService } from '../prisma/prisma.service';
// import { buildPromptInferAction } from './prompts/buildPromptInferAction';
// import { SessionsService } from '../sessions/sessions.service';
// import { UserDataUpdate } from '../user/user.dto';
// export type UserIntentEnum =
// | "payment_status"
// | "view_enrolled_classes"
// | "view_new_keys"
// | "enroll_new_classes";
// /** Intents permitidos por rol (ajusta si querés diferenciar más) */
// const ROLE_ALLOWED: Record<UserRole, ReadonlyArray<UserIntentEnum>> = {
// [UserRole.ADMIN]: [
// "payment_status",
// "view_enrolled_classes",
// "view_new_keys",
// "enroll_new_classes",
// ],
// [UserRole.INSTRUCTOR]: [
// "payment_status",
// "view_enrolled_classes",
// "view_new_keys",
// "enroll_new_classes",
// ],
// [UserRole.USER]: [
// "payment_status",
// "view_enrolled_classes",
// "view_new_keys",
// "enroll_new_classes",
// ],
// // Invitado: no puede ver pagos ni “mis clases”
// [UserRole.GUEST]: [],
// };
// /** Normaliza texto para matching robusto */
// const norm = (s: string) =>
// s
// .normalize("NFKD")
// .replace(/\p{Diacritic}/gu, "")
// .toLowerCase()
// .trim();
// /** Sinónimos rápidos por intent (ES + algo de EN) */
// const KEYWORDS: Record<UserIntentEnum, RegExp[]> = {
// payment_status: [
// /pago(s)?\b/,
// /\bestado.*pago/,
// /\bestoy.*al dia/,
// /\bal dia\b/,
// /factur(a|as)|cuota|vencim(iento|entos)/,
// /\bpagar\b|\bpague\b/,
// /payment|paid|invoice|bill|due/,
// ],
// view_enrolled_classes: [
// /mis?\s+clases/,
// /clases?.*inscrit[oa]s?/,
// /inscripc(ion|iones)\s+vigentes?/,
// /\bver\s+clases\b|\bmis\s+cursos?\b/,
// /matriculad[oa]s?/,
// /enrolled|my\s+classes|my\s+courses/,
// ],
// view_new_keys: [
// /claves?\s+(nuevas?|disponibles?)/,
// /\bnuevas?\s+claves?\b/,
// /\bcodigos?\b|\baccesos?\b|\bkeys?\b/,
// /generate.*key|new.*key/,
// ],
// enroll_new_classes: [
// /inscrib(irme|ir|irnos)|anotar(me)?|apuntar(me)?|registrar(me)?/,
// /inscripcion\s+nuev(a|as)|nuevas?\s+clases/,
// /\bquiero\s+(sumarme|anotarme|inscribirme)\b/,
// /enroll|sign\s*up|register.*class/,
// ],
// };
// /** Heurística: primer intent cuyo regex matchee */
// const quickHeuristic = (text: string): UserIntentEnum | null => {
// const t = norm(text);
// for (const intent of Object.keys(KEYWORDS) as UserIntentEnum[]) {
// const found = KEYWORDS[intent].some((rx) => rx.test(t));
// if (found) return intent;
// }
// return null;
// };
// /** Prompt builder acotado por rol */
// export const buildPromptInferUserMenuAction = (
// message: string,
// role: UserRole
// ): string => {
// const allowed = ROLE_ALLOWED[role];
// const labels = allowed
// .map((k) => `"${k}"`)
// .join(", ");
// // Unos mini-ejemplos para anclar
// const examples = [
// { in: "necesito ver si estoy al dia con mis pagos", out: "payment_status" },
// { in: "quiero ver mis clases inscritas", out: "view_enrolled_classes" },
// { in: "tienen nuevas claves de acceso?", out: "view_new_keys" },
// { in: "quiero inscribirme a una clase nueva", out: "enroll_new_classes" },
// ]
// // filtra ejemplos que no estén permitidos al rol para no confundir al modelo
// .filter((e) => (allowed as string[]).includes(e.out))
// .map((e) => `Entrada: "${e.in}"\nRespuesta: "${e.out}"`)
// .join("\n\n");
// return `
// Eres un asistente que clasifica la intención del usuario en una de las siguientes opciones PERMITIDAS según su rol (${role}):
// ${labels}
// Analiza el siguiente mensaje y responde SOLO con una de esas claves exactas (sin texto adicional).
// Si la intención NO coincide con ninguna de las opciones permitidas, responde exactamente "null".
// ${examples ? `\nEjemplos:\n\n${examples}\n` : ""}
// Entrada: "${message}"
// Respuesta:
// `.trim();
// };
// /** Timeout helper: evita congelar la UX si la IA tarda */
// const withTimeout = async <T>(
// p: Promise<T>,
// ms = 1200
// ): Promise<T> =>
// Promise.race([
// p,
// new Promise<T>((_, rej) => setTimeout(() => rej(new Error("IA_TIMEOUT")), ms)),
// ]) as Promise<T>;
// /** Mapea string → intent válido o null */
// const parseModelLabel = (
// raw: string | null | undefined,
// allowed: ReadonlyArray<UserIntentEnum>
// ): UserIntentEnum | null => {
// if (!raw) return null;
// const cleaned = raw.replace(/[\s"'`]/g, "").toLowerCase();
// if (cleaned === "null") return null;
// // Intent labels exactos
// const candidates: UserIntentEnum[] = [
// "payment_status",
// "view_enrolled_classes",
// "view_new_keys",
// "enroll_new_classes",
// ];
// const match = candidates.find((c) => c === cleaned);
// if (!match) return null;
// // Respeta permisos por rol
// return allowed.includes(match) ? match : null;
// };
// export enum ActionsAdmin {
// CREATE_SESSION = 'create_session',
// GET_SESSIONS = 'get_sessions',
// UPDATE_USER = 'update_user',
// GET_USERS = 'get_users',
// }
// //xport type SessionWithSchedules = Partial<Prisma.SessionGetPayload<{ include: { schedules: true } }>>
// @Injectable()
// export class IaService implements OnModuleInit {
// constructor(
// private readonly httpService: HttpService,
// private prisma: PrismaService,
// ) { }
// private readonly logger = new Logger(IaService.name);
// private readonly apiUrl = 'http://localhost:5000/v1/completions';
// //@ts-ignores
// private tokenizer: Tiktoken;
// private readonly ctxSize = 4096; // tamaño de contexto que configuraste en el modelo
// private readonly maxTokens = 512; // tokens máximos que le permitimos al modelo generar
// private readonly safetyMargin = 10; // margen de seguridad
// onModuleInit() {
// this.tokenizer = get_encoding('cl100k_base');
// }
// /** Implementa la llamada real a tu LLM/endpoint */
// private async callModel(prompt: string): Promise<string> {
// // TODO: reemplaza por tu cliente real (OpenAI, local, etc.)
// // Debe devolver el texto "payment_status" | ... | "null"
// throw new Error("callModelNot implemented");
// }
// async detectIntent(
// text: string,
// role: UserRole
// ): Promise<UserIntentEnum | null> {
// const allowed = ROLE_ALLOWED[role];
// // 1) Heurística rápida (barata y rápida)
// const byHeuristic = quickHeuristic(text);
// if (byHeuristic && allowed.includes(byHeuristic)) {
// return byHeuristic;
// }
// // 2) LLM (con timeout) usando prompt limitado por rol
// try {
// const prompt = buildPromptInferUserMenuAction(text, role);
// const raw = await withTimeout(this.callModel(prompt), 1500) as string | null;
// const intent = parseModelLabel(raw, allowed);
// if (intent) return intent;
// } catch {
// // caemos a null si hay timeout/errores
// }
// // 3) Nada concluyente
// return null;
// }
// async getActionAdmin(input: string): Promise<ActionsAdmin> {
// try {
// const prompt = buildPromptInferAction(input);
// const response = await this.executePrompt<string>(prompt)
// if (!Object.values(ActionsAdmin).includes(response as ActionsAdmin)) {
// this.logger.error(`Acción no válida: ${response}`);
// throw new Error('La acción inferida no es válida.');
// }
// return response as ActionsAdmin;
// }
// catch (error: any) {
// this.logger.error('Error al obtener la acción de administrador', error);
// throw new Error('No se pudo obtener la acción de administrador.');
// }
// }
// async getUserUpdatePayload(input: string): Promise<UserDataUpdate> {
// try {
// const prompt = this.buildPromptUpdateUser(input);
// const promptTokens = this.countTokens(prompt);
// // Validación: aseguramos que no exceda el tamaño de contexto
// if (promptTokens + this.maxTokens + this.safetyMargin > this.ctxSize) {
// this.logger.error(`Prompt demasiado largo. Tokens en prompt: ${promptTokens}`);
// throw new Error('El prompt es demasiado largo para el contexto disponible.');
// }
// const { data } = await firstValueFrom(
// this.httpService.post(this.apiUrl, {
// prompt,
// max_tokens: 256,
// temperature: 0.2,
// stop: ['Entrada:', '\n\n'],
// }),
// );
// const { choices } = data;
// const choice = choices[0];
// if (choice.finish_reason !== 'stop') {
// throw new Error('La respuesta fue incompleta o abortada.');
// }
// const responseText = choice.text.trim();
// // Parseamos el JSON que viene como string
// const jsonResponse = JSON.parse(responseText);
// const response: UserDataUpdate = {
// customId: jsonResponse.customId,
// name: jsonResponse.name,
// phone: jsonResponse.phone.replace(/\D/g, ''), // Eliminamos todo lo que no sea dígito
// birth: dayjs(jsonResponse.birth).toDate(),
// }
// return response;
// }
// catch (error: any) {
// this.logger.error('Error al obtener el payload de actualización del usuario', error);
// throw new Error('No se pudo obtener el payload de actualización del usuario.');
// }
// }
// //TODO cambiarle el nombre , lo que hace es parsear de natural a json
// // async createSession(input: string): Promise<SessionWithSchedules> {
// // const prompt = this.buildPromptCreateSession(input);
// // const promptTokens = this.countTokens(prompt);
// // // Validación: aseguramos que no exceda el tamaño de contexto
// // if (promptTokens + this.maxTokens + this.safetyMargin > this.ctxSize) {
// // this.logger.error(`Prompt demasiado largo. Tokens en prompt: ${promptTokens}`);
// // throw new Error('El prompt es demasiado largo para el contexto disponible.');
// // }
// // try {
// // const { data } = await firstValueFrom(
// // this.httpService.post(this.apiUrl, {
// // prompt,
// // max_tokens: 512,
// // temperature: 0.2,
// // stop: ['\n\n', 'Entrada:'],
// // }),
// // );
// // const { choices } = data;
// // const choice = choices[0];
// // if (choice.finish_reason !== 'stop') {
// // throw new Error('La respuesta fue incompleta o abortada.');
// // }
// // const responseText = choice.text.trim();
// // // Parseamos el JSON que viene como string
// // const jsonResponse = JSON.parse(responseText);
// // const response: SessionWithSchedules = {
// // description: jsonResponse.description,
// // type: jsonResponse.type,
// // schedules: jsonResponse?.schedules?.map((item: any) => {
// // return {
// // dayOfWeek: item.dayOfWeek || null,
// // specificDate: item.specificDate ? dayjs(item.specificDate).toDate() : null,
// // startTime: dayjs(item.startDateTime).toDate(),
// // endTime: dayjs(item.endDateTime).toDate(),
// // }
// // }),
// // }
// // return response;
// // } catch (error) {
// // this.logger.error('Error al generar la sesión', error);
// // throw new Error('No se pudo generar la sesión.');
// // }
// // }
// private countTokens(text: string): number {
// const tokens = this.tokenizer.encode(text);
// return tokens.length;
// }
// async executePrompt<T>(
// prompt: string,
// options?: {
// maxTokens?: number;
// temperature?: number;
// stop?: string[];
// }
// ): Promise<T> {
// const promptTokens = this.countTokens(prompt);
// // Configuración por defecto
// const maxTokens = options?.maxTokens ?? 256;
// const temperature = options?.temperature ?? 0.2;
// const stop = options?.stop ?? ['Entrada:', '\n\n'];
// if (promptTokens + maxTokens + this.safetyMargin > this.ctxSize) {
// this.logger.error(`Prompt demasiado largo. Tokens en prompt: ${promptTokens}`);
// throw new Error('El prompt es demasiado largo para el contexto disponible.');
// }
// const { data } = await firstValueFrom(
// this.httpService.post(this.apiUrl, {
// prompt,
// max_tokens: maxTokens,
// temperature,
// stop,
// }),
// );
// const { choices } = data;
// const choice = choices[0];
// if (choice.finish_reason !== 'stop') {
// throw new Error('La respuesta fue incompleta o abortada.');
// }
// const responseText = choice.text.trim();
// try {
// return JSON.parse(responseText) as T;
// } catch (error) {
// this.logger.error('Error al parsear la respuesta JSON:', error);
// throw new Error('La respuesta no es un JSON válido.');
// }
// }
// private buildPromptCreateSession(message: string): string {
// return `
// Eres un asistente que solo responde en formato JSON válido y nunca agrega texto adicional.
// Devuelve solo la estructura JSON con este formato:
// {
// "description": string,
// "type": "RECURRING" o "ONE_TIME",
// "schedules": [
// {
// "dayOfWeek": string o null,
// "specificDate": string o null //en formato DD-MM-YYYY HH-mm ,
// "startDateTime": string //en formato DD-MM-YYYY HH-mm,
// "endDateTime": string //en formato DD-MM-YYYY HH-mm
// }
// ]
// }
// Reglas importantes:
// - Extrae siempre la fecha y hora que aparecen escritas en el mensaje. No inventes, no uses la fecha ni la hora actuales.
// - Si el mensaje contiene una fecha específica (por ejemplo, "2/7/2025"), úsala exactamente como aparece para los campos "specificDate", "startDateTime" y "endDateTime".
// - Si el evento es recurrente, usa palabras clave como "recurrente", "todos los", "cada", "se repite" para detectarlo. En ese caso, el campo "specificDate" debe ser null, "dayOfWeek" debe tener el nombre del día en inglés, y las horas deben colocarse con una fecha ficticia: "1970-01-01".
// - Si el evento es puntual, el campo "dayOfWeek" debe ser null y "specificDate" debe ser la fecha real extraída del mensaje.
// - Convierte siempre las horas al formato de 24 horas y en formato ISO: yyyy-mm-ddTHH:MM:SSZ.
// - Si el mensaje tiene un horario como "11:00AM - 14:00PM", interpreta que:
// - "11:00AM" es la hora de inicio
// - "14:00PM" es la hora de fin (corrige a formato 24h si es necesario)
// Ejemplos:
// Entrada: "Clase 'Zumba' 15/12/2025 de 8:30am a 10:00am"
// Respuesta:
// {
// "description": "Zumba",
// "type": "ONE_TIME",
// "schedules": [
// {
// "dayOfWeek": null,
// "specificDate": "2025-12-15T08:30:00Z",
// "startDateTime": "2025-12-15T08:30:00Z",
// "endDateTime": "2025-12-15T10:00:00Z"
// }
// ]
// }
// Entrada: "Clase Yoga recurrente lunes de 8:30am a 10:00am"
// Respuesta:
// {
// "description": "Yoga",
// "type": "RECURRING",
// "schedules": [
// {
// "dayOfWeek": "Monday",
// "specificDate": null,
// "startDateTime": "1970-01-01T08:30:00Z",
// "endDateTime": "1970-01-01T10:00:00Z"
// }
// ]
// }
// Entrada: ${message}
// Respuesta:
// `.trim();
// }
// private buildPromptUpdateUser(message: string): string {
// return `
// Eres un asistente que responde únicamente en formato JSON válido. No agregues texto adicional ni explicaciones.
// Formato esperado:
// {
// "customId": string, // Ejemplo: "U-001"
// "name": string, // Nombre completo
// "phone": string, // Solo dígitos, sin espacios ni símbolos
// "birth": string // Fecha en formato ISO: YYYY-MM-DD
// }
// Reglas:
// - El primer token siempre es el customId (ejemplo: U-001).
// - El nombre puede incluir espacios y letras, ignora palabras como "nombre".
// - El teléfono puede venir con espacios o símbolos, pero debes devolver solo los dígitos.
// - La fecha de nacimiento puede venir como DD/MM/YYYY o D/M/YYYY y debes convertirla a formato YYYY-MM-DD.
// Ejemplo:
// Entrada:
// U-001 nombre Leandro Leones 3435077510 18/12/1995
// Salida:
// {
// "customId": "U-001",
// "name": "Leandro Leones",
// "phone": "3435077510",
// "birth": "1995-12-18"
// }
// Entrada:
// ${message}
// Salida:
// `.trim();
// }
// }
@@ -0,0 +1,42 @@
export const buildPromptGetUser = (message: string): string => {
return `
Eres un asistente que interpreta mensajes para buscar usuarios en la base de datos.
Analiza el siguiente mensaje y responde solo con un JSON así:
{
"nombre": string | null, // nombre o parte del nombre del usuario a buscar, o null si no se especifica
"birthdayMonth": number | null // número del mes de cumpleaños del usuario (1 = enero, 12 = diciembre), o null si no se especifica
}
Reglas:
- Si el usuario menciona un nombre o parte del nombre, debes colocar ese valor en "nombre".
- Si el usuario menciona un mes de cumpleaños (por ejemplo "cumpleaños en mayo"), debes colocar el número correspondiente en "birthdayMonth".
- Si no se especifica un dato, debes ponerlo como null.
Ejemplos:
Entrada: "Buscar usuarios que se llaman Juan y cumplen en mayo"
Respuesta:
{
"nombre": "Juan",
"birthdayMonth": 5
}
Entrada: "Buscar usuarios que cumplen en noviembre"
Respuesta:
{
"nombre": null,
"birthdayMonth": 11
}
Entrada: "Buscar usuarios llamados María"
Respuesta:
{
"nombre": "María",
"birthdayMonth": null
}
Entrada: ${message}
Respuesta:
`.trim();
};
@@ -0,0 +1,27 @@
export const buildPromptInferAction = (message: string): string => {
return `
Eres un asistente que clasifica la intención de un usuario.
Analiza el siguiente mensaje y devuelve solo una de las siguientes claves JSON válidas según la intención detectada:
- "create_session" si el usuario quiere crear una sesión, agendar, programar, registrar una clase o similar.
- "update_user" si el usuario quiere actualizar personales, cambiar nombre, cambiar teléfono, modificar información de usuario.
- "get_sessions" si el usuario quiere ver las sesiones, clases, horarios, agenda, calendario o similar.
- "get_users" si el usuario quiere ver o listar usuarios, personas, alumnos, estudiantes o similar.
Solo responde la clave exacta sin agregar texto adicional.
Ejemplo:
Entrada: "Quiero crear una nueva clase para mañana"
Respuesta: "create_session"
Entrada: "Necesito actualizar el telefono del usuario U-001"
Respuesta: "update_user"
Entrada: "Quiero ver las clases para mañana"
Respuesta: "get_sessions"
Entrada: ${message}
Respuesta:
`.trim();
}
@@ -0,0 +1,46 @@
export const buildPromptGetSession = (message: string): string => {
return `
Eres un asistente que interpreta mensajes para buscar sesiones en la base de datos.
Analiza el siguiente mensaje y responde solo con un JSON así:
{
"modo": "filtrar" | "proxima", // "filtrar" si el usuario quiere buscar clases específicas, "proxima" si solo quiere saber cuál es la próxima clase sin filtros
"descripcion": string | null, // palabra clave (ej: yoga), o null
"instructor": string | null, // nombre del instructor, o null
"assistant": string | null, // nombre del asistente, o null
"temporalReference": string | null, // frase sobre fecha u hora, sin convertir. Ej: "lunes siguiente a las 11 AM"
"daysOfWeek": string[] // array con días de la semana en español, puede estar vacío
}
Reglas:
- Si el usuario pregunta por "la próxima clase", "la siguiente clase", o algo similar, debes responder con modo: "proxima" y dejar los otros campos en null o vacío.
- Si el usuario busca por nombre de clase, día o instructor, responde con modo: "filtrar" y llena los campos correspondientes.
Ejemplos:
Entrada: "Dame las clases del lunes siguiente a las 11 AM de yoga con el profesor Juan"
Respuesta:
{
"modo": "filtrar",
"descripcion": "yoga",
"instructor": "Juan",
"assistant": null,
"temporalReference": "lunes siguiente a las 11 AM",
"daysOfWeek": ["LUNES"]
}
Entrada: "¿Cuál es la próxima clase?"
Respuesta:
{
"modo": "proxima",
"descripcion": null,
"instructor": null,
"assistant": null,
"temporalReference": null,
"daysOfWeek": []
}
Entrada: ${message}
Respuesta:
`.trim();
}
@@ -0,0 +1,45 @@
export const builderSubscriptionPrompt = (message: string): string => {
return `
Eres un asistente que solo responde en formato JSON válido y nunca agrega texto adicional.
Debes inferir y devolver solo la estructura JSON con este formato:
{
"user": {
"name": string,
"customId": string | null
},
"product": {
"description": string,
"customId": string | null
},
"discount": number | null,
"mode": "assistant" | "instructor"
}
Reglas:
- El "customId" es un string con el formato: una letra, seguida de un guion y tres caracteres (ejemplo: A-123). Si no está presente, devolver null.
- El "discount" debe ser un número si el mensaje hace referencia a un descuento, caso contrario devolver null.
- El "mode" debe ser "assistant" por defecto. Si el mensaje menciona que el usuario es un profesor o instructor, el modo debe ser "instructor".
- El nombre debe inferirse como el nombre completo de la persona mencionada.
- La descripción debe inferirse como el nombre de la clase o producto mencionado.
Ejemplo:
Entrada: "Agregar a valentin romero a la clase de latino con un descuento del 30%"
Respuesta:
{
"user": {
"name": "valentin romero",
"customId": null
},
"product": {
"description": "latino",
"customId": null
},
"discount": 30,
"mode": "assistant"
}
Entrada: ${message}
Respuesta:
`.trim();
}
@@ -0,0 +1,255 @@
import { HttpService } from '@nestjs/axios';
import { Injectable, Logger } from '@nestjs/common';
import { Session, User, Prisma, } from '@prisma/client';
import { firstValueFrom, lastValueFrom } from 'rxjs';
import { PrismaService } from '../../prisma/prisma.service';
// import { IaService } from '../ia.service';
import { link } from 'fs';
import { builderSubscriptionPrompt } from './builderSubscriptionPrompt';
type SubscriptionWithDataInvoices = {
id: string;
userId: string;
sessionId: string;
base64: string;
linkPayment: string;
}
type CreateInvoiceOdoo = {
user: {
userCode: string;
dni?: string;
}
product: {
description: string;
productCode: string;
amount: number;
}
}
type ResponseInvoiceOdoo = {
linkPayment: string;
linkInvoice: string; // URL de la factura
invoice: string // base 64
}
type ResponseSubscribeFromNaturalMessage = {
message: string;
invoice64: string;
linkPayment: string;
}
type ProductsModels = Session
@Injectable()
export class IaSubscriptionService {
private readonly logger = new Logger(IaSubscriptionService.name);
constructor(
// private iaService: any, // Asegúrate de importar IaService correctamente
private readonly prisma: PrismaService,
private httpService: HttpService, // Asegúrate de importar HttpService si lo necesitas
) {
this.logger.log('IaSubscriptionService initialized');
}
async subscribeFromNaturalMessage(input: string, esAdmin: boolean = false): Promise<ResponseSubscribeFromNaturalMessage> {
try {
//const a = await this.iaService.
type ResponseIaSubscription = {
user: {
name: string,
customId: string,
},
product: {
description: string,
customId: string,
},
discount?: number,
mode: 'assistant' | 'instructor',
}
const prompt = builderSubscriptionPrompt(input);
const { data } = await firstValueFrom(
this.httpService.post(process.env.URL_IA!, {
prompt,
max_tokens: 256,
temperature: 0.2,
stop: ['Entrada:', '\n\n'],
}),
);
const { choices } = data;
const choice = choices[0];
if (choice.finish_reason !== 'stop') {
throw new Error('La respuesta fue incompleta o abortada.');
}
const responseText = choice.text.trim();
// Parseamos el JSON que viene como string
const jsonResponse = JSON.parse(responseText);
// TODO: Replace this with actual response from IA service
const response = jsonResponse as ResponseIaSubscription;
if (!response) {
throw new Error('No response from IA service');
}
const nameParts = response.user.name?.split(' ').filter(part => part.length >= 3) || [];
if (nameParts.length < 2 && !response.user.customId) {
throw new Error('No se pudo inferir suficiente información para buscar el usuario.');
}
const whereConditions: any = {};
if (response.user.customId) {
whereConditions.customId = response.user.customId;
} else if (nameParts.length >= 2) {
whereConditions.AND = [
{ name: { contains: nameParts[0], mode: 'insensitive' } },
{ name: { contains: nameParts[nameParts.length - 1], mode: 'insensitive' } }
];
}
const user = await this.prisma.user.findMany({
where: whereConditions,
take: 1
});
if (user.length === 0) {
throw new Error('User not found');
} else if (user.length > 1) {
this.logger.warn(`Multiple users found for query: ${JSON.stringify(whereConditions)}. Using the first one.`);
}
const descriptionParts = response.product.description?.split(' ').filter(part => part.length >= 3) || [];
if (descriptionParts.length === 0 && !response.product.customId) {
throw new Error('No se pudo inferir suficiente información para buscar el producto.');
}
const whereProductConditions: any = {};
if (response.product.customId) {
whereProductConditions.customId = response.product.customId;
} else {
whereProductConditions.AND = [
// oj que busca solo por el primer
{ description: { contains: descriptionParts[0], mode: 'insensitive' } },
// { description: { contains: descriptionParts[descriptionParts.length - 1], mode: 'insensitive' } }
];
}
const modelName = 'session'; // Asumiendo que el modelo es Session
const product = await this.prisma?.[modelName].findMany({
where: whereProductConditions
});
if (product.length === 0) {
throw new Error('Producto no encontrado.');
}
if (product.length > 1) {
throw new Error('La búsqueda devolvió múltiples productos. Por favor, proporciona más información para identificarlo correctamente.');
}
if (!user || !product) {
throw new Error('User or product not found');
}
if (!response.mode) {
this.logger.debug(`No mode provided for subscription, defaulting to 'assistant'`);
}
const subscribe = await this.subscribe(user[0], product[0], response.mode);
const responseService = {
message: `User ${user[0].name} subscribed to product ${product[0].description} with mode ${response.mode}. Invoice ID: ${subscribe.id}`,
invoice64: subscribe.base64, // Asumiendo que la factura es un base64
linkPayment: subscribe.linkPayment, // URL de pago
}
return responseService
}
catch (error: any) {
throw new Error(`Failed to subscribe: ${error.message}`);
}
}
// Ejemplo de método para suscribirse
async subscribe(user: User, product: ProductsModels, mode: 'assistant' | 'instructor'): Promise<SubscriptionWithDataInvoices> {
try {
this.logger.log(`User ${user.id} subscribed`);
// Use explicit model access instead of dynamic string indexing
const updateProduct = await this.prisma.session.update({
where: { id: product.id },
data: {
...(mode === 'instructor' ? { instructors: { connect: { id: user.id } } } : {}), // Conectar el usuario como instructor
...(mode === 'assistant' ? { assistants: { connect: { id: user.id } } } : {}), // Conectar el usuario como asistente
}
})
const createInvoiceOdoo: CreateInvoiceOdoo = {
user: {
userCode: user.id,
dni: user.dni || undefined, // Asumiendo que el usuario tiene un campo dni
},
product: {
description: updateProduct.description,
productCode: updateProduct.customId,
amount: updateProduct.amount || 11, // Monto ficticio
}
}
const { data } = await lastValueFrom(
this.httpService.post<ResponseInvoiceOdoo>(
process.env.URL_INVOICE_ODOO!, // URL ficticia del API de Odoo
createInvoiceOdoo as CreateInvoiceOdoo , {
headers: {
'Content-Type': 'application/json',
}}
)
)
//@ts-ignore
const { invoice, linkInvoice, linkPayment } = data['result'] || {}
// Crear factura directamente sin suscripción intermedia
const createdInvoice = await this.prisma.invoice.create({
data: {
userId: user.id,
sessionId: product.id,
amount: product.amount || 1, // Monto ficticio
dateInvoice: new Date(), // Mes actual
base64Invoice: invoice,
linkPayment: linkPayment,
status: 'PENDING'
}
});
// Retornar en formato compatible
return {
id: createdInvoice.id,
userId: user.id,
sessionId: product.id,
base64: invoice,
linkPayment
};
}
catch (error: any) {
throw new Error(`Failed to subscribe user ${user.id}: ${error.message}`);
}
}
// Ejemplo de método para cancelar suscripción
unsubscribe(userId: string): string {
this.logger.log(`User ${userId} unsubscribed`);
return `User ${userId} unsubscribed successfully`;
}
}
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { InvoicesController } from './invoices.controller';
import { InvoicesService } from './invoices.service';
describe('InvoicesController', () => {
let controller: InvoicesController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [InvoicesController],
providers: [InvoicesService],
}).compile();
controller = module.get<InvoicesController>(InvoicesController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
@@ -0,0 +1,84 @@
import { Controller, ForbiddenException, Get, Param, Res, NotFoundException, UseGuards, Put, Body } from '@nestjs/common';
import { Response } from 'express';
import { InvoicesService } from './invoices.service';
import { User } from '~/common/decoratos/user.decorator';
import { AuthUser } from '~/common/types';
import { Invoice, UserRole, InvoiceStatus } from '@prisma/client';
import { AuthTokenGuard } from '~/common/guards/auth-token.guard';
@Controller('invoices')
@UseGuards(AuthTokenGuard)
export class InvoicesController {
constructor(private readonly invoicesService: InvoicesService) { }
@Get(':userId')
async getAttendeesSnapshot(
@Param('userId') userId: string,
@User() user: AuthUser,
): Promise<(Invoice & { description: string })[] | null> {
const allowed = user?.role === UserRole.ADMIN || user?.role === UserRole.INSTRUCTOR || user?.id === userId;
if (!allowed) {
throw new ForbiddenException('No tienes permisos para ver facturas');
}
return this.invoicesService.findInvoicesByUserId(userId);
}
@Get('download/:invoiceId')
async downloadInvoicePdf(
@Param('invoiceId') invoiceId: string,
@User() user: AuthUser,
@Res() res: Response,
): Promise<void> {
// Buscar la factura con las relaciones necesarias
const invoice = await this.invoicesService.findById(invoiceId);
if (!invoice) {
throw new NotFoundException('Factura no encontrada');
}
// Verificar permisos - debe ser admin, instructor o el dueño de la factura
const allowed = user?.role === UserRole.ADMIN ||
user?.role === UserRole.INSTRUCTOR ||
invoice.userId === user?.id;
if (!allowed) {
throw new ForbiddenException('No tienes permisos para descargar esta factura');
}
// Verificar que la factura tiene contenido PDF
if (!invoice.base64Invoice) {
throw new NotFoundException('La factura no tiene contenido PDF disponible');
}
try {
// Decodificar el base64
const pdfBuffer = Buffer.from(invoice.base64Invoice, 'base64');
// Configurar headers para la descarga
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="factura-${invoice.id}.pdf"`);
res.setHeader('Content-Length', pdfBuffer.length);
// Enviar el archivo
res.send(pdfBuffer);
} catch (error) {
throw new NotFoundException('Error al procesar el archivo PDF');
}
}
@Put(':userId/:invoiceId')
async updateInvoiceStatus(
@Param('userId') userId: string,
@Param('invoiceId') invoiceId: string,
@Body('status') status: InvoiceStatus,
@User() user: AuthUser,
): Promise<Invoice> {
// Verificar permisos - solo admin o instructor pueden actualizar facturas
const allowed = user?.role === UserRole.ADMIN || user?.role === UserRole.INSTRUCTOR;
if (!allowed) {
throw new ForbiddenException('No tienes permisos para actualizar facturas');
}
return this.invoicesService.updateInvoiceStatus(userId, invoiceId, status, user);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { forwardRef, Module } from '@nestjs/common';
import { InvoicesService } from './invoices.service';
import { InvoicesController } from './invoices.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { OdooModule } from '../odoo/odoo.module';
@Module({
imports: [
PrismaModule,
forwardRef(() => OdooModule),
],
controllers: [InvoicesController],
providers: [InvoicesService],
exports: [InvoicesService],
})
export class InvoicesModule { }
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { InvoicesService } from './invoices.service';
describe('InvoicesService', () => {
let service: InvoicesService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [InvoicesService],
}).compile();
service = module.get<InvoicesService>(InvoicesService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
+404
View File
@@ -0,0 +1,404 @@
import { forwardRef, Inject, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { Invoice, InvoiceStatus } from '@prisma/client';
import dayjs from 'dayjs';
import { OdooInvoiceInput } from '../odoo/entities/odoo.entity';
import { OdooService } from '../odoo/odoo.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuthUser } from '~/common/types';
@Injectable()
export class InvoicesService {
private logger = new Logger(InvoicesService.name);
constructor(
private prisma: PrismaService,
@Inject(forwardRef(() => OdooService))
private readonly odooService: OdooService,
) { }
/**
* Comprueba si el usuario tiene alguna factura no pagada en el mes actual.
* @param userId ID del usuario
* @returns true si existe al menos una factura con status PENDING o CANCELED dentro del mes en curso
*/
async hasUnpaidInvoiceThisMonth(userId: string): Promise<boolean> {
this.logger.log(`Checking unpaid invoices for user ${userId} in the current month`);
const startOfMonth = dayjs().startOf('month').toDate();
const endOfMonth = dayjs().endOf('month').toDate();
const unpaidInvoice = await this.prisma.invoice.findFirst({
where: {
userId,
status: {
in: [InvoiceStatus.PENDING]
},
dateInvoice: {
gte: startOfMonth,
lte: endOfMonth
}
}
});
return !!unpaidInvoice;
}
async getLinkPayment(userId: string): Promise<string | null> {
try {
return 'www.mercadopago.com.ar/asd'
}
catch (error: any) {
this.logger.error(`Error getting payment link for user ${userId}: ${error.message}`);
return null;
}
}
/**
* Devuelve la suma de los precios efectivos de todas las sesiones
* en las que el usuario es asistente y que tienen al menos un día
* dentro del mes actual.
*
* @param userId ID del usuario
* @returns total en la moneda del sistema (Float)
*/
async getTotalAssistantSessionAmount(userId: string): Promise<number> {
const startOfMonth = dayjs().startOf('month').toDate();
const endOfMonth = dayjs().endOf('month').toDate();
// 1️⃣ Obtener todas las sesiones donde el usuario es asistente
const sessions = await this.prisma.session.findMany({
where: {
assistants: { some: { id: userId } },
},
include: {
priceHistories: true, // historial de precios
},
});
let total = 0;
// 2️⃣ Para cada sesión, encontrar el precio que estaba vigente
// en el rango del mes y sumarlo
for (const s of sessions) {
// Usar la nueva lógica corregida para obtener el precio del mes
const priceForMonth = this.getCurrentPriceForDate(s, startOfMonth);
total += priceForMonth;
}
this.logger.log(
`Total effective amount for assistants of user ${userId} this month: ${total}`,
);
return total;
}
async newInvoiceForSession(userId: string, sessionId: string, dateInvoice: Date = new Date(), prismaClient?: any) {
try {
// Usar el cliente de transacción si se proporciona, o el cliente normal
const client = prismaClient || this.prisma;
// Obtener información de la sesión para el monto y descripción
const session = await client.session.findUnique({
where: { id: sessionId },
include: { priceHistories: true }
});
if (!session) {
this.logger.error(`Session not found: ${sessionId}`);
throw new InternalServerErrorException('Sesión no encontrada');
}
// Obtener el precio efectivo para el mes de la factura
const currentPrice = this.getCurrentPriceForDate(session, dateInvoice);
const invoice = await client.invoice.create({
data: {
userId,
sessionId,
amount: currentPrice,
dateInvoice,
status: InvoiceStatus.PENDING,
}
});
if (!invoice) {
this.logger.error(`Failed to create invoice for user ${userId} and session ${sessionId}`);
throw new InternalServerErrorException('Error al crear la factura');
}
// Crear factura en Odoo
const user = await client.user.findUnique({ where: { id: userId } });
if (!user) {
this.logger.error(`User not found: ${userId}`);
throw new InternalServerErrorException('Usuario no encontrado');
}
const data: OdooInvoiceInput = {
product: {
amount: currentPrice,
description: session.description,
invoiceId: invoice.id,
sessionId: session.id
},
user: {
dni: user.dni || '000000',
id: user.id
}
};
const facturaOdoo = await this.odooService.createInvoice(data);
if (!facturaOdoo) {
this.logger.error(`Error creating invoice in Odoo for user ${userId} and session ${sessionId}`);
throw new InternalServerErrorException('Error al crear la factura en Odoo');
}
const updatedInvoice = await client.invoice.update({
where: { id: invoice.id },
data: {
base64Invoice: facturaOdoo.result.base64Invoice,
linkPayment: facturaOdoo.result.linkPayment,
}
});
this.logger.log(`Invoice created successfully for user ${userId} and session ${sessionId} with invoice ID ${updatedInvoice.id} , amount: ${updatedInvoice.amount}, dateService : ${dateInvoice}`);
return updatedInvoice;
} catch (error: any) {
this.logger.error(`Error creating invoice: ${error.message}`, error.stack);
throw new InternalServerErrorException('Error al crear la factura');
}
}
/**
* Obtiene el precio vigente de una sesión para una fecha determinada
*/
private getCurrentPriceForDate(session: any, targetDate: Date): number {
if (!session.priceHistories?.length) {
return session.amount || 0;
}
// Ordenar por fecha de inicio (más reciente primero)
const sortedHistory = session.priceHistories
.sort((a: any, b: any) => new Date(b.effectiveFrom).getTime() - new Date(a.effectiveFrom).getTime());
// Buscar el precio aplicable para la fecha objetivo
const applicablePrice = sortedHistory.find((h: any) => {
const from = new Date(h.effectiveFrom);
const to = h.effectiveTo ? new Date(h.effectiveTo) : null;
// Si es el precio vigente (effectiveTo = null) y la fecha es posterior al inicio
if (!to && from <= targetDate) {
return true;
}
// Si tiene fecha de fin, verificar que esté en el rango
if (to && from <= targetDate && targetDate < to) {
return true;
}
return false;
});
return applicablePrice ? applicablePrice.amount : session.amount || 0;
}
async findInvoiceThisMonthByUserId(userId: string): Promise<(Invoice & { description: string })[] | null> {
try {
const startOfMonth = dayjs().startOf('month').toDate();
const endOfMonth = dayjs().endOf('month').toDate();
const invoices = await this.prisma.invoice.findMany({
where: {
userId,
OR: [
{
dateInvoice: {
gte: startOfMonth,
lte: endOfMonth
}
},
{
status: InvoiceStatus.PENDING
}
]
},
include: {
session: true
}
});
const facturas: (Invoice & { description: string })[] = invoices.map(invoice => ({
...invoice,
description: invoice.session.description
}));
return facturas || null;
} catch (error: any) {
this.logger.error(`Error fetching invoices for user ${userId}: ${error.message}`, error.stack);
throw new InternalServerErrorException('Error al buscar las facturas');
}
}
async findInvoicesByUserId(userId: string): Promise<(Invoice & { description: string })[] | null> {
try {
const invoices = await this.prisma.invoice.findMany({
where: { userId },
include: {
session: true
}
});
const facturas: (Invoice & { description: string })[] = invoices.map(invoice => ({
...invoice,
description: invoice.session.description
}));
return facturas || null;
} catch (error: any) {
this.logger.error(`Error fetching invoices for user ${userId}: ${error.message}`, error.stack);
throw new InternalServerErrorException('Error al buscar las facturas');
}
}
async updateStatus(invoiceId: string, status: InvoiceStatus) {
try {
const invoice = await this.prisma.invoice.update({
where: { id: invoiceId },
data: { status }
});
return invoice;
}
catch (error: any) {
this.logger.error(`Error updating invoice status: ${error.message}`, error.stack);
throw new InternalServerErrorException('Error al actualizar el estado de la factura');
}
}
async findById(id: string) {
try {
return await this.prisma.invoice.findUnique({
where: { id },
include: {
user: true,
session: true
}
});
} catch (error: any) {
this.logger.error('Error al buscar invoice por id', error?.message);
throw new InternalServerErrorException('Error al buscar la factura');
}
}
async updateInvoiceStatus(userId: string, invoiceId: string, status: InvoiceStatus, actionBy?: AuthUser): Promise<Invoice> {
const actionByInfo = actionBy ? `${actionBy.name || actionBy.id} (${actionBy.role})` : 'Unknown';
this.logger.log(`Updating invoice status - userId: ${userId}, invoiceId: ${invoiceId}, newStatus: ${status}, actionBy: ${actionByInfo}`);
try {
// Verificar que la factura existe y pertenece al usuario
const invoice = await this.prisma.invoice.findFirst({
where: {
id: invoiceId,
userId: userId
}
});
if (!invoice) {
this.logger.warn(`Invoice not found or doesn't belong to user - invoiceId: ${invoiceId}, userId: ${userId}, attemptedBy: ${actionByInfo}`);
throw new InternalServerErrorException('Factura no encontrada o no pertenece al usuario');
}
this.logger.log(`Found invoice - currentStatus: ${invoice.status}, updating to: ${status}, actionBy: ${actionByInfo}`);
// Si el estado es PAID o CANCELED, actualizar en Odoo primero
if (status === InvoiceStatus.PAID || status === InvoiceStatus.CANCELED) {
this.logger.log(`Updating invoice status in Odoo - invoiceId: ${invoiceId}, status: ${status}`);
try {
await this.odooService.updateInvoiceStatus(invoiceId, status);
this.logger.log(`Invoice status updated in Odoo successfully - invoiceId: ${invoiceId}`);
} catch (odooError: any) {
this.logger.error(`Failed to update invoice status in Odoo - invoiceId: ${invoiceId}, error: ${odooError.message}`);
throw new InternalServerErrorException(`Error al actualizar el estado en Odoo: ${odooError.message}`);
}
}
// Actualizar el estado en la base de datos
const updatedInvoice = await this.prisma.invoice.update({
where: { id: invoiceId },
data: { status },
include: {
user: true,
session: true
}
});
this.logger.log(`Invoice status updated successfully - invoiceId: ${invoiceId}, from: ${invoice.status} to: ${status}, by: ${actionByInfo}`);
return updatedInvoice;
} catch (error: any) {
this.logger.error(`Error updating invoice status by ${actionByInfo}: ${error.message}`, error.stack);
// Si el error ya es de Odoo, re-lanzarlo
if (error instanceof InternalServerErrorException) {
throw error;
}
throw new InternalServerErrorException('Error al actualizar el estado de la factura');
}
}
/**
* Crea facturas para todos los usuarios asistentes de sesiones activas
* para el mes especificado (o mes actual si no se especifica)
*/
async createMonthlyInvoicesForActiveAssistants(month?: Date) {
try {
const targetMonth = month || new Date();
const startOfMonth = dayjs(targetMonth).startOf('month').toDate();
this.logger.log(`Creating monthly invoices for ${dayjs(targetMonth).format('YYYY-MM')}`);
// Obtener todas las sesiones activas con sus asistentes
const activeSessions = await this.prisma.session.findMany({
where: { isActive: true },
include: {
assistants: true,
priceHistories: true
}
});
const invoicesCreated = [];
for (const session of activeSessions) {
for (const assistant of session.assistants) {
// Verificar que no exista ya una factura para este usuario, sesión y mes
const existingInvoice = await this.prisma.invoice.findFirst({
where: {
userId: assistant.id,
sessionId: session.id,
dateInvoice: {
gte: startOfMonth,
lt: dayjs(targetMonth).endOf('month').toDate()
}
}
});
if (!existingInvoice) {
const invoice = await this.newInvoiceForSession(
assistant.id,
session.id,
startOfMonth
);
invoicesCreated.push(invoice);
}
}
}
this.logger.log(`Created ${invoicesCreated.length} invoices for ${dayjs(targetMonth).format('YYYY-MM')}`);
return invoicesCreated;
}
catch (error: any) {
this.logger.error(`Error creating monthly invoices: ${error.message}`, error.stack);
throw new InternalServerErrorException('Error en la creación masiva de facturas');
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import 'reflect-metadata';
import { AppModule } from './app.module.js';
// ─── Silenciar logs verbosos de libsignal-node ────────────────────────────────
// @whiskeysockets/libsignal-node emite console.info hardcodeado para gestión
// interna de sesiones E2E (Closing/Opening session, etc.). Es comportamiento
// normal del protocolo Signal; no aporta valor en producción.
const _originalConsoleInfo = console.info.bind(console);
const LIBSIGNAL_PATTERNS = [
'Closing session:',
'Opening session:',
'Removing old closed session:',
'Migrating session to:',
];
console.info = (...args: any[]) => {
const msg = String(args[0] ?? '');
if (LIBSIGNAL_PATTERNS.some((p) => msg.includes(p))) return;
_originalConsoleInfo(...args);
};
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Pipes de validación
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
// CORS
app.enableCors({
origin: '*',
credentials: true,
});
// Configuración de Swagger
const config = new DocumentBuilder()
.setTitle('Bot WhatsApp API')
.setDescription('API de gestión de usuarios, sesiones, asistencia, facturación y configuración del sistema')
.setVersion('1.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Ingresa tu token de autenticación',
},
'bearer',
)
.addTag('Configuración del Sistema', 'Endpoints para gestionar la configuración dinámica del sistema')
.addTag('Autenticación', 'Endpoints de autenticación y gestión de tokens')
.addTag('Usuarios', 'Gestión de usuarios del sistema')
.addTag('Sesiones', 'Gestión de sesiones y clases')
.addTag('Facturas', 'Gestión de facturación')
.addTag('Asistencia', 'Control de asistencia')
.addTag('WhatsApp', 'Estado y gestión del bot de WhatsApp')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document, {
customSiteTitle: 'Bot WhatsApp API - Documentación',
customfavIcon: 'https://nestjs.com/img/logo-small.svg',
customCss: '.swagger-ui .topbar { display: none }',
});
const port = process.env.PORT || 3001;
await app.listen(Number(port), '0.0.0.0');
console.log(`Server running on http://localhost:${port}`);
console.log(`Swagger documentation available at http://localhost:${port}/api/docs`);
}
bootstrap().catch((err) => {
console.error('Error starting application:', err);
process.exit(1);
});
+29
View File
@@ -0,0 +1,29 @@
export type OdooInvoiceInput = {
user: {
id: string;
dni: string;
},
product: {
description: string;
invoiceId: string;
sessionId: string;
amount: number;
}
}
export type OdooInvoiceResponse = {
jsonrpc: string,
id: string,
result: {
invoiceId: string;
base64Invoice: string; // PDF en base64
linkPayment: string; // URL para pagar la factura
}}
export type OdooWebhook = {
invoiceId: string;
status: 'PENDING' | 'PAID' | 'CANCELED';
}
+20
View File
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OdooController } from './odoo.controller';
import { OdooService } from './odoo.service';
describe('OdooController', () => {
let controller: OdooController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [OdooController],
providers: [OdooService],
}).compile();
controller = module.get<OdooController>(OdooController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
+19
View File
@@ -0,0 +1,19 @@
import { Body, Controller, Post, Res } from '@nestjs/common';
import { OdooWebhook } from './entities/odoo.entity';
import { OdooService } from './odoo.service';
import { Response } from 'express';
@Controller('odoo')
export class OdooController {
constructor(private readonly odooService: OdooService) { }
@Post('/webhook')
async webhook(
@Body() data: OdooWebhook,
@Res() res: Response
) {
await this.odooService.webhook(data);
return res.status(200).send('OK');
}
}
+15
View File
@@ -0,0 +1,15 @@
import { forwardRef, Module } from '@nestjs/common';
import { OdooService } from './odoo.service';
import { OdooController } from './odoo.controller';
import { HttpModule } from '@nestjs/axios';
import { InvoicesModule } from '../invoices/invoices.module';
@Module({
controllers: [OdooController],
providers: [OdooService],
imports: [HttpModule,
forwardRef(() => InvoicesModule),
],
exports: [OdooService],
})
export class OdooModule { }
+18
View File
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OdooService } from './odoo.service';
describe('OdooService', () => {
let service: OdooService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [OdooService],
}).compile();
service = module.get<OdooService>(OdooService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
File diff suppressed because one or more lines are too long
+10
View File
@@ -0,0 +1,10 @@
// src/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+11
View File
@@ -0,0 +1,11 @@
// src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy {
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
}
+1
View File
@@ -0,0 +1 @@
export class CreateRfidDto {}
+4
View File
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateRfidDto } from './create-rfid.dto';
export class UpdateRfidDto extends PartialType(CreateRfidDto) {}
+1
View File
@@ -0,0 +1 @@
export class Rfid {}
+20
View File
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RfidController } from './rfid.controller';
import { RfidService } from './rfid.service';
describe('RfidController', () => {
let controller: RfidController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [RfidController],
providers: [RfidService],
}).compile();
controller = module.get<RfidController>(RfidController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
+19
View File
@@ -0,0 +1,19 @@
import { Body, Controller, Header, HttpCode, Post } from '@nestjs/common';
import { RfidService } from './rfid.service';
@Controller('rfid')
// TODO ponerle un token estatico a los lectores RFID ?
// @UseGuards(AuthTokenGuard)
export class RfidController {
constructor(private readonly rfidService: RfidService) { }
@Post('ping')
@HttpCode(200)
@Header('Content-Type', 'text/plain')
async ping(@Body('id') uid: string): Promise<string> {
console.log(`RFID ping recibido con id=${uid}`);
await this.rfidService.ping(uid);
return 'ok'
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { InvoicesModule } from '../invoices/invoices.module';
import { AttendanceModule } from '../attendance/attendance.module';
import { UserModule } from '../user/user.module';
import { WhatsappModule } from '../whatsapp/whatsapp.module';
import { RfidController } from './rfid.controller';
import { RfidService } from './rfid.service';
import { AuthModule } from '~/auth/auth.module';
@Module({
controllers: [RfidController],
providers: [RfidService],
imports: [UserModule,AttendanceModule,WhatsappModule,AttendanceModule,InvoicesModule,AuthModule]
})
export class RfidModule { }
+18
View File
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RfidService } from './rfid.service';
describe('RfidService', () => {
let service: RfidService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [RfidService],
}).compile();
service = module.get<RfidService>(RfidService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
+96
View File
@@ -0,0 +1,96 @@
import { Injectable, Logger } from '@nestjs/common';
import { LogDirection } from '@prisma/client';
import dayjs from 'dayjs';
import { AuthService, TokenRedirectURL } from '~/auth/auth.service';
import { AttendanceService } from '../attendance/attendance.service';
import { InvoicesService } from '../invoices/invoices.service';
import { UserService } from '../user/user.service';
import { WhatsappService } from '../whatsapp/whatsapp.service';
@Injectable()
export class RfidService {
private logger = new Logger(RfidService.name);
constructor(
private readonly userService: UserService,
private readonly whatsappService: WhatsappService,
private readonly attendanceService: AttendanceService,
private readonly invoicesService: InvoicesService,
private readonly authService: AuthService,
) { }
async ping(uid: string): Promise<void> {
try {
const usuario = await this.userService.findByRfid(uid);
const admin = await this.userService.getUserAdmin();
if (usuario) {
if (!usuario.phone) {
this.logger.debug(`El usuario ${usuario.name} no tiene un número de teléfono registrado.`);
const number = admin?.phone ? `549${admin.phone}` : '5493435077510'
this.logger.log({
action: 'RFID ping',
message: `El usuario ${usuario.name} con ID ${usuario.id} no tiene un número de teléfono registrado.`,
uid: uid,
userId: usuario.id,
})
await this.whatsappService.sendText(
number,
`El usuario ${usuario.name} con ID ${usuario.id} no tiene un número de teléfono registrado. Por favor, actualiza su información.`);
} else {
this.logger.log(`RFID ping recibido con id=${uid} del usuario ${usuario.name}`);
const log = await this.attendanceService.logAccess(usuario.id);
const tipo = log.direction === LogDirection.INGRESS ? 'ingreso' : 'egreso';
const hora = dayjs(log.timestamp).format('HH:mm');
// ---- Construir un único mensaje ----
let message = `Hola ${usuario.name} ! \n`;
if (log.direction === LogDirection.INGRESS) {
this.logger.log(`Registro de ingreso para el usuario ${usuario.name} a las ${hora}`);
message += `Registramos tu ${tipo} a las ${hora}. \n`;
// Verificar factura pagada
const unpaidInvoice = await this.invoicesService.hasUnpaidInvoiceThisMonth(usuario.id);
if (unpaidInvoice) {
this.logger.warn(`El usuario ${usuario.name} tiene facturas no pagadas este mes.`);
message += `⚠️ Notamos que tienes facturas no pagadas este mes. Por favor, regulariza tu situación.`;
}
} else {
// Si es egreso, solo avisamos el registro
message += `Registramos tu ${tipo} a las ${hora}.`;
}
// Enviar el mensaje único
await this.whatsappService.sendText('549' + usuario.phone, message);
}
return;
} else {
//NOTE mando al admin que se esta registrando un un nuevo usuario
this.logger.error({
action: 'RFID ping',
message: `RFID ping recibido con id=${uid} pero no se encontró el usuario`,
uid: uid,
})
const newUser = await this.userService.createUserTemporary(uid);
this.logger.debug(`Usuario temporal creado: ${newUser.name} con ID ${newUser.id}`);
if (!admin) {
this.logger.error(`No se encontró un usuario administrador para notificar sobre el nuevo usuario con RFID ${uid}.`);
return;
}
const token = await this.authService.generarToken(admin.id, TokenRedirectURL.USER);
await this.whatsappService.sendText('549' + admin?.phone,
`🆕 Se registro un *nuevo usuario* a travez de un nuevo *llavero*.\n
🤔 Nombre: *${newUser.name}*\n
Porfavor actualiza su información ingresando al link 🙏\n
${process.env.URL_FRONT}?t=${token}
`);
return;
}
}
catch (error: any) {
this.logger.error(`Error al procesar el ping RFID: ${error}`);
}
}
}
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SchedulingController } from './scheduling.controller';
import { SchedulingService } from './scheduling.service';
describe('SchedulingController', () => {
let controller: SchedulingController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SchedulingController],
providers: [SchedulingService],
}).compile();
controller = module.get<SchedulingController>(SchedulingController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
@@ -0,0 +1,7 @@
import { Controller } from '@nestjs/common';
import { SchedulingService } from './scheduling.service';
@Controller('scheduling')
export class SchedulingController {
constructor(private readonly schedulingService: SchedulingService) {}
}
@@ -0,0 +1,24 @@
import { forwardRef, Module } from '@nestjs/common';
import { SchedulingService } from './scheduling.service';
import { SchedulingController } from './scheduling.controller';
import { SessionsModule } from '~/sessions/sessions.module';
import { ScheduleModule } from '@nestjs/schedule';
import { InvoicesModule } from '~/invoices/invoices.module';
import { SystemConfigService } from '~/common/system-config.service';
import { PrismaModule } from '~/prisma/prisma.module';
import { UserModule } from '~/user/user.module';
import { AuthModule } from '~/auth/auth.module';
@Module({
controllers: [SchedulingController],
providers: [SchedulingService, SystemConfigService],
imports: [
ScheduleModule.forRoot(),
SessionsModule,
InvoicesModule,
PrismaModule,
forwardRef(() => UserModule),
forwardRef(() => AuthModule),
]
})
export class SchedulingModule { }
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SchedulingService } from './scheduling.service';
describe('SchedulingService', () => {
let service: SchedulingService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SchedulingService],
}).compile();
service = module.get<SchedulingService>(SchedulingService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
@@ -0,0 +1,155 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InvoicesService } from '~/invoices/invoices.service';
import { PrismaService } from '~/prisma/prisma.service';
import { SessionsService } from '~/sessions/sessions.service';
import { SystemConfigService } from '~/common/system-config.service';
@Injectable()
export class SchedulingService {
private readonly logger = new Logger(SchedulingService.name);
private lastSessionCleanup: number = 0;
private lastTokenCleanup: number = 0;
constructor(
private readonly sessions: SessionsService,
private readonly invoices: InvoicesService,
private readonly prisma: PrismaService,
private readonly systemConfig: SystemConfigService,
) { }
// Desactiva sesiones vencidas cada 30 minutos (configurable)
@Cron(CronExpression.EVERY_MINUTE, { name: 'deactivateExpiredSessions' })
async deactivateExpiredSessionsJob() {
// Verificar si debe ejecutarse según la configuración
const intervalMinutes = this.systemConfig.getSessionCleanupInterval();
const now = Date.now();
if (now - this.lastSessionCleanup < intervalMinutes * 60 * 1000) {
return; // Aún no es tiempo de ejecutar
}
this.lastSessionCleanup = now;
try {
const count = await this.sessions.deactivateExpiredActives();
if (count > 0) {
this.logger.log(`Desactivadas ${count} sesiones vencidas.`);
} else {
this.logger.verbose('No había sesiones vencidas para desactivar.');
}
} catch (err: any) {
this.logger.error('Error desactivando sesiones vencidas', err?.stack || err);
}
}
// Todos los meses se deben de generar las facturas y enlaces de pagos
// para que el usuario pueda pagar la clase que este inscripto y activa.
// Se ejecuta diariamente y verifica si es el día configurado del mes
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT, { name: 'generateMonthlyInvoices' })
async generateMonthlyInvoicesAndPaymentLinksJob() {
const today = new Date().getDate();
const configuredDay = this.systemConfig.getInvoiceGenerationDay();
// Solo ejecutar si es el día configurado del mes
if (today !== configuredDay) {
return;
}
let totalProcessed = 0;
let totalSuccess = 0;
let totalFailed = 0;
const errors: Array<{ sessionId: string; assistantId: string; error: string }> = [];
try {
const sessions = await this.sessions.findAllSessionInThisMonth();
this.logger.log(`Iniciando generación de facturas para ${sessions.length} sesiones`);
for (const session of sessions) {
const { id: sessionId, assistants } = session;
for (const assistant of assistants) {
totalProcessed++;
try {
await this.invoices.newInvoiceForSession(
assistant.id,
sessionId,
new Date()
);
totalSuccess++;
this.logger.verbose(
`✓ Factura creada: session=${sessionId}, assistant=${assistant.id}`
);
} catch (invoiceErr: any) {
totalFailed++;
const errorMsg = invoiceErr?.message || String(invoiceErr);
errors.push({
sessionId,
assistantId: assistant.id,
error: errorMsg
});
this.logger.warn(
`✗ Error creando factura: session=${sessionId}, assistant=${assistant.id}, error=${errorMsg}`
);
}
}
}
// Resumen final
this.logger.log(
`Generación de facturas completada: ` +
`Total=${totalProcessed}, Éxito=${totalSuccess}, Fallos=${totalFailed}`
);
if (errors.length > 0) {
this.logger.error(
`Se encontraron ${errors.length} errores durante la generación:`,
JSON.stringify(errors, null, 2)
);
}
} catch (err: any) {
this.logger.error(
'Error crítico generando facturas mensuales y enlaces de pago',
err?.stack || err
);
}
}
// Limpia tokens expirados cada hora (configurable)
@Cron(CronExpression.EVERY_MINUTE, { name: 'cleanupExpiredTokens' })
async cleanupExpiredTokensJob() {
// Verificar si debe ejecutarse según la configuración
const intervalMinutes = this.systemConfig.getTokenCleanupInterval();
const now = Date.now();
if (now - this.lastTokenCleanup < intervalMinutes * 60 * 1000) {
return; // Aún no es tiempo de ejecutar
}
this.lastTokenCleanup = now;
try {
const result = await this.prisma.token.deleteMany({
where: {
expiresAt: {
lt: new Date(), // Eliminar los expirados
},
},
});
if (result.count > 0) {
this.logger.log(`Eliminados ${result.count} tokens expirados`);
} else {
this.logger.verbose('No había tokens expirados para eliminar.');
}
} catch (err: any) {
this.logger.error('Error limpiando tokens expirados', err?.stack || err);
}
}
}
@@ -0,0 +1,98 @@
import {
IsArray,
IsEnum,
IsOptional,
IsString,
IsDateString,
ValidateNested,
IsObject,
IsNotEmpty,
IsNumber,
} from 'class-validator';
import { Type } from 'class-transformer';
import { SessionType } from '@prisma/client';
export enum ClaseTipo {
RECURRING = 'RECURRING',
ONE_TIME = 'ONE_TIME',
}
class ProfesorDto {
@IsString()
customId!: string;
}
class DateRangeDto {
@IsDateString()
start!: string;
@IsDateString()
end!: string;
}
export class CreateClaseDto {
@IsString()
title!: string;
@IsObject()
@ValidateNested()
@Type(() => ProfesorDto)
profesor!: ProfesorDto;
@IsEnum(ClaseTipo)
type!: ClaseTipo;
@IsOptional()
@IsArray()
@IsString({ each: true })
days?: string[] | null;
@IsArray()
@ValidateNested({ each: true })
@Type(() => DateRangeDto)
dates!: DateRangeDto[];
@IsDateString()
startDate!: string;
@IsDateString()
endDate!: string;
}
export class UpsertClaseDto {
@IsOptional()
@IsString()
id?: string; // si viene, edita; si no, crea
@IsString()
@IsNotEmpty()
title!: string;
@IsEnum(SessionType)
type!: SessionType;
@IsDateString()
startDate!: string;
@IsDateString()
endDate!: string;
@ValidateNested()
@Type(() => ProfesorDto)
profesor!: ProfesorDto;
@IsArray()
@ValidateNested({ each: true })
@Type(() => DateRangeDto)
dates!: DateRangeDto[];
@IsNumber()
amount: number | undefined
}
export class SetParticipantsDto {
@IsArray()
@IsString({ each: true })
userIds!: string[]; // IDs finales seleccionados
}
@@ -0,0 +1,15 @@
import { IsNumberString, IsString, Matches } from 'class-validator';
export class InvoicesByMonthQueryDto {
@IsNumberString()
@Matches(/^(0[1-9]|1[0-2])$/, {
message: 'month debe ser un número entre 01 y 12'
})
month!: string;
@IsNumberString()
@Matches(/^\d{4}$/, {
message: 'year debe ser un año de 4 dígitos'
})
year!: string;
}
@@ -0,0 +1,20 @@
import { InvoiceStatus } from '@prisma/client';
export interface AssistantInvoiceDto {
id: string;
name: string;
hasInvoice: boolean;
invoiceStatus: InvoiceStatus | null;
invoiceId?: string | null;
amount?: number | null;
}
export interface InvoicesByMonthResponseDto {
sessionId: string;
sessionCustomId: string;
sessionDescription: string;
sessionType: string;
month: string;
year: string;
assistants: AssistantInvoiceDto[];
}
@@ -0,0 +1 @@
export type DeactivateBody = { modelId: string, model: 'session' | 'snapshot' }
+131
View File
@@ -0,0 +1,131 @@
import { Body, Controller, ForbiddenException, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { UserRole } from '@prisma/client';
import { User } from '../common/decoratos/user.decorator';
import { AuthTokenGuard } from '../common/guards/auth-token.guard';
import { AuthUser } from '../common/types';
import { SetParticipantsDto } from './dto/create-session.dto';
import { InvoicesByMonthQueryDto } from './dto/invoices-by-month-query.dto';
import { InvoicesByMonthResponseDto } from './dto/invoices-by-month-response.dto';
import { DeactivateBody } from './entities/session.entity';
import { ResponseAttendeesSnapshot, SessionsService } from './sessions.service';
@Controller('sessions')
@UseGuards(AuthTokenGuard)
export class SessionsController {
constructor(private readonly sessionsService: SessionsService) { }
@Get()
async findAll(
@User() user: AuthUser,
@Query('status') status?: 'active' | 'inactive' | 'all',
) {
return await this.sessionsService.findAll(user, status);
}
@Post('/upsert')
async upsert(
@Body() body: any, //UpsertClaseDto,
@User() user: AuthUser,
) {
const allowed = user?.role === UserRole.ADMIN || user?.role === UserRole.INSTRUCTOR;
if (!allowed) {
throw new ForbiddenException('No tienes permisos para crear o actualizar clases');
}
const { session, created } = await this.sessionsService.upsertSession(body, user?.id);
return {
message: created ? 'Clase creada correctamente' : 'Clase actualizada correctamente',
data: session,
};
}
@Get(':id/participants')
async getParticipants(
@Param('id') id: string,
@User() user: AuthUser,
) {
const allowed = user?.role === UserRole.ADMIN || user?.role === UserRole.INSTRUCTOR;
if (!allowed) {
return { message: 'No tienes permisos para actualizar participantes', data: null };
}
return this.sessionsService.getParticipantsLists(id);
}
@Put(':id/participants')
async setParticipants(
@Param('id') id: string,
@Body() body: SetParticipantsDto,
@User() user: AuthUser,
) {
// Permisos: ADMIN o INSTRUCTOR de la sesión
const allowed = user?.role === UserRole.ADMIN || user?.role === UserRole.INSTRUCTOR;
if (!allowed) {
return { message: 'No tienes permisos para actualizar participantes', data: null };
}
const session = await this.sessionsService.setParticipants(id, body.userIds, user);
return { message: 'Participantes actualizados', data: session };
}
@Put(':id/snapshot')
async setSubstituteInstructor(
@Param('id') idSnapshot: string,
@Body('substituteInstructorId') substituteInstructorId: string[],
@User() user: AuthUser,
) {
const allowed = user?.role === UserRole.ADMIN
if (!allowed) {
return { message: 'No tienes permisos para actualizar el instructor suplente', data: null };
}
const session = await this.sessionsService.setSubstituteInstructor(
idSnapshot, substituteInstructorId);
return { message: 'Instructor suplente actualizado', data: session };
}
@Get(':id/attendees')
async getAttendeesSnapshot(
@Param('id') id: string,
@User() user: AuthUser,
): Promise<ResponseAttendeesSnapshot> {
const allowed = user?.role === UserRole.ADMIN || user?.role === UserRole.INSTRUCTOR;
if (!allowed) {
throw new ForbiddenException('No tienes permisos para ver los asistentes');
}
return this.sessionsService.getAttendeesSnapshot(id);
}
@Post('/deactivate')
async deactivate(
@Body() body: DeactivateBody,
@User() user: AuthUser,
) {
const allowed = user?.role === UserRole.ADMIN
if (!allowed) {
throw new ForbiddenException('No tienes permisos para desactivar');
}
return await this.sessionsService.deactivate(body, user);
}
@Get(':sessionId/invoices-by-month')
async getInvoicesByMonth(
@Param('sessionId') sessionId: string,
@Query() query: InvoicesByMonthQueryDto,
@User() user: AuthUser,
): Promise<InvoicesByMonthResponseDto> {
// Permitir acceso a ADMIN e INSTRUCTOR
const allowed = user?.role === UserRole.ADMIN || user?.role === UserRole.INSTRUCTOR;
if (!allowed) {
throw new ForbiddenException('No tienes permisos para ver las facturas');
}
return await this.sessionsService.getInvoicesByMonth(
sessionId,
query.month,
query.year
);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { forwardRef, Module } from '@nestjs/common';
import { SessionsService } from './sessions.service';
import { SessionsController } from './sessions.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { CustomIdModule } from '../custom-id/custom-id.module';
import { InvoicesModule } from '~/invoices/invoices.module';
@Module({
controllers: [SessionsController],
providers: [SessionsService],
imports: [PrismaModule, CustomIdModule, forwardRef(() => InvoicesModule)],
exports: [SessionsService],
})
export class SessionsModule { }
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SessionsService } from './sessions.service';
describe('SessionsService', () => {
let service: SessionsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SessionsService],
}).compile();
service = module.get<SessionsService>(SessionsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
export class CreateSubscriptionDto {}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateSubscriptionDto } from './create-subscription.dto';
export class UpdateSubscriptionDto extends PartialType(CreateSubscriptionDto) {}
@@ -0,0 +1 @@
export class Subscription {}
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SubscriptionsController } from './subscriptions.controller';
import { SubscriptionsService } from './subscriptions.service';
describe('SubscriptionsController', () => {
let controller: SubscriptionsController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SubscriptionsController],
providers: [SubscriptionsService],
}).compile();
controller = module.get<SubscriptionsController>(SubscriptionsController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
@@ -0,0 +1,34 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { SubscriptionsService } from './subscriptions.service';
import { CreateSubscriptionDto } from './dto/create-subscription.dto';
import { UpdateSubscriptionDto } from './dto/update-subscription.dto';
@Controller('subscriptions')
export class SubscriptionsController {
constructor(private readonly subscriptionsService: SubscriptionsService) {}
// @Post()
// create(@Body() createSubscriptionDto: CreateSubscriptionDto) {
// return this.subscriptionsService.create(createSubscriptionDto);
// }
// @Get()
// findAll() {
// return this.subscriptionsService.findAll();
// }
// @Get(':id')
// findOne(@Param('id') id: string) {
// return this.subscriptionsService.findOne(+id);
// }
// @Patch(':id')
// update(@Param('id') id: string, @Body() updateSubscriptionDto: UpdateSubscriptionDto) {
// return this.subscriptionsService.update(+id, updateSubscriptionDto);
// }
// @Delete(':id')
// remove(@Param('id') id: string) {
// return this.subscriptionsService.remove(+id);
// }
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { SubscriptionsService } from './subscriptions.service';
import { SubscriptionsController } from './subscriptions.controller';
import { SessionsModule } from '../sessions/sessions.module';
import { UserModule } from '../user/user.module';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
controllers: [SubscriptionsController],
providers: [SubscriptionsService],
imports: [
SessionsModule,
UserModule,
PrismaModule
],
exports: [SubscriptionsService],
})
export class SubscriptionsModule { }
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SubscriptionsService } from './subscriptions.service';
describe('SubscriptionsService', () => {
let service: SubscriptionsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SubscriptionsService],
}).compile();
service = module.get<SubscriptionsService>(SubscriptionsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
@@ -0,0 +1,155 @@
import { BadRequestException, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { SessionsService } from '../sessions/sessions.service';
import { UserService } from '~/user/user.service';
@Injectable()
export class SubscriptionsService {
private logger = new Logger(SubscriptionsService.name);
constructor(
private prismaService: PrismaService,
private sessionsService: SessionsService,
private userService: UserService,
) { }
/**
* Suscribe un usuario a una sesión como asistente
* @param userId ID del usuario
* @param sessionId ID de la sesión
* @returns información de la inscripción
*/
async subscribeUserToSession(userId: string, sessionId: string) {
try {
this.logger.log(`Subscribing user ${userId} to session ${sessionId}`);
const session = await this.sessionsService.findById(sessionId);
const user = await this.userService.findById(userId);
if (!session || !user) {
this.logger.warn(`Session or user not found (userId: ${userId}, sessionId: ${sessionId})`);
throw new BadRequestException('Usuario o sesión no encontrados');
}
// Verificar si el usuario ya es asistente de esta sesión
const isAlreadyAssistant = session?.assistants.some(assistant => assistant.id === userId);
if (isAlreadyAssistant) {
this.logger.warn(`User ${userId} is already an assistant in session ${sessionId}`);
throw new BadRequestException('El usuario ya es asistente en esta sesión');
}
// Agregar el usuario como asistente a la sesión
const updatedSession = await this.prismaService.session.update({
where: { id: sessionId },
data: {
assistants: {
connect: { id: userId }
}
},
include: {
assistants: true
}
});
return {
message: 'Usuario suscrito exitosamente',
sessionId: sessionId,
userId: userId,
sessionDescription: session?.description
};
} catch (error: any) {
// Si es una excepción HTTP específica, no la transformamos
if (error instanceof BadRequestException || error instanceof InternalServerErrorException) {
this.logger.error(`Error subscribing user ${userId} to session ${sessionId}: ${error.message}`);
throw error;
}
// Solo para errores no controlados
this.logger.error(`Unexpected error subscribing user ${userId} to session ${sessionId}: ${error.message}`, error.stack);
throw new InternalServerErrorException('Error al suscribir al usuario');
}
}
/**
* Desuscribe un usuario de una sesión
* @param userId ID del usuario
* @param sessionId ID de la sesión
*/
async unsubscribeUserFromSession(userId: string, sessionId: string) {
try {
this.logger.log(`Unsubscribing user ${userId} from session ${sessionId}`);
const session = await this.sessionsService.findById(sessionId);
if (!session) {
this.logger.warn(`Session not found: ${sessionId}`);
throw new BadRequestException('Sesión no encontrada');
}
// Verificar si el usuario es asistente de esta sesión
const isAssistant = session.assistants.some(assistant => assistant.id === userId);
if (!isAssistant) {
this.logger.warn(`User ${userId} is not an assistant in session ${sessionId}`);
throw new BadRequestException('El usuario no es asistente en esta sesión');
}
// Remover el usuario como asistente de la sesión
await this.prismaService.session.update({
where: { id: sessionId },
data: {
assistants: {
disconnect: { id: userId }
}
}
});
return {
message: 'Usuario desuscrito exitosamente',
sessionId: sessionId,
userId: userId
};
} catch (error: any) {
if (error instanceof BadRequestException || error instanceof InternalServerErrorException) {
this.logger.error(`Error unsubscribing user ${userId} from session ${sessionId}: ${error.message}`);
throw error;
}
this.logger.error(`Unexpected error unsubscribing user ${userId} from session ${sessionId}: ${error.message}`, error.stack);
throw new InternalServerErrorException('Error al desuscribir al usuario');
}
}
/**
* Obtiene todas las sesiones en las que un usuario es asistente
* @param userId ID del usuario
*/
async getUserSessions(userId: string) {
try {
this.logger.log(`Getting sessions for user ${userId}`);
const user = await this.prismaService.user.findUnique({
where: { id: userId },
include: {
assistantSessions: {
include: {
instructors: true,
dates: true
}
}
}
});
if (!user) {
throw new BadRequestException('Usuario no encontrado');
}
return user.assistantSessions;
} catch (error: any) {
this.logger.error(`Error getting sessions for user ${userId}: ${error.message}`);
throw new InternalServerErrorException('Error al obtener las sesiones del usuario');
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards, ForbiddenException, NotFoundException } from '@nestjs/common';
import { UserService } from './user.service.js';
import { User as UserModel, UserRole } from '@prisma/client';
import { UpsertUserDto } from './user.dto.js';
import { AuthTokenGuard } from '~/common/guards/auth-token.guard';
import { User } from '~/common/decoratos/user.decorator';
import { AuthUser } from '~/common/types';
@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) { }
@Get()
async getUsers(): Promise<UserModel[]> {
console.log('=== GET /users endpoint called ===');
const users = await this.userService.findAll();
console.log(`Found ${users.length} users`);
return users;
}
@Get('instructor')
async getProfesors(): Promise<UserModel[]> {
return await this.userService.findAllProfesors();
}
@UseGuards(AuthTokenGuard)
@Get(':userId/profile-picture')
async getProfilePicture(
@Param('userId') userId: string,
@User() user: AuthUser
): Promise<{ profilePicture: string | null; updatedAt: Date | null }> {
// Verificar permisos - debe ser admin, instructor o el mismo usuario
const allowed = user?.role === UserRole.ADMIN ||
user?.role === UserRole.INSTRUCTOR ||
user?.id === userId;
if (!allowed) {
throw new ForbiddenException('No tienes permisos para ver esta foto de perfil');
}
const foundUser = await this.userService.findById(userId);
if (!foundUser) {
throw new NotFoundException('Usuario no encontrado');
}
return {
profilePicture: foundUser.profilePicture || null,
updatedAt: foundUser.profilePictureUpdatedAt || null
};
}
@Post()
async upsertUser(@Body() body: UpsertUserDto) {
return this.userService.upsert(body);
}
@Put(':userId/active')
async toogleActivateUser(
@Body() body: { deleted: Date | null },
@Param('userId') userId: string
) {
return this.userService.toogleActivateUser(userId, body);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { User, UserRole } from "@prisma/client";
import { IsDateString, IsEnum, IsOptional, IsString } from "class-validator";
// Define the UserFilter type
export type UserFilter = {
nombre?: string;
birthdayMonth?: number;
};
export type UserDataUpdate = Pick<User, 'name' | 'phone' | 'birth' | 'customId'>
export class UpsertUserDto {
@IsOptional()
@IsString()
id?: string; // si viene, actualizamos
@IsString()
customId!: string;
@IsString()
name!: string;
@IsOptional()
@IsString()
dni?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsDateString()
birth?: string;
@IsOptional()
@IsString()
rfid?: string;
@IsOptional()
@IsDateString()
deleted?: string;
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { UserController } from './user.controller.js';
import { UserService } from './user.service.js';
import { PrismaModule } from '../prisma/prisma.module.js';
import { CustomIdService } from '../custom-id/custom-id.service.js';
import { CustomIdModule } from '../custom-id/custom-id.module.js';
@Module({
controllers: [UserController],
providers: [UserService],
imports: [PrismaModule , CustomIdModule],
exports: [UserService],
})
export class UserModule {}
+446
View File
@@ -0,0 +1,446 @@
import { ConflictException, ForbiddenException, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common';
import { Prisma, User, UserRole } from '@prisma/client';
import { CustomIdService } from '../custom-id/custom-id.service.js';
import { PrismaService } from '../prisma/prisma.service.js';
import { UpsertUserDto, UserDataUpdate, UserFilter } from './user.dto.js';
import { NotFoundError } from 'rxjs';
@Injectable()
export class UserService {
constructor(
private readonly prisma: PrismaService,
private readonly customIdService: CustomIdService
) { }
private readonly logger = new Logger(UserService.name);
async findById(id: string): Promise<User | null> {
try {
return await this.prisma.user.findUnique({ where: { id } });
} catch (error: any) {
this.logger.error(`Error al buscar usuario por id: ${id}`, error);
throw new NotFoundException('Usuario no encontrado');
}
}
async upsert(dto: UpsertUserDto) {
this.logger.log(`Iniciando upsert de usuario: ${JSON.stringify(dto)}`);
const id = dto.id ?? undefined;
const customId = dto.customId?.trim() || undefined;
const where = id ? { id } : null;
const base = {
name: dto.name,
dni: dto.dni ?? '',
phone: dto.phone ?? '',
rfid: dto.rfid ?? '',
birth: dto.birth ? new Date(dto.birth) : null,
deleted: dto.deleted ? new Date(dto.deleted) : null,
role: dto.role ?? UserRole.USER,
} as const;
try {
if (!where) {
return await this.prisma.user.create({
data: {
...base,
customId: customId ?? '',
},
});
}
const exists = await this.prisma.user.findUnique({ where });
if (exists) {
return await this.prisma.user.update({
where,
data: {
...base,
...(customId !== undefined ? { customId } : {}),
},
});
} else {
return await this.prisma.user.create({
data: {
...base,
customId: customId ?? '',
},
});
}
} catch (error: any) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === 'P2002' &&
String(error.meta?.target).includes('phone')
) {
throw new ConflictException('El teléfono ya está registrado, debe ser único.');
}
}
this.logger.error(`Error al realizar upsert del usuario: ${error.message}`, error.stack);
throw new InternalServerErrorException('Error interno al guardar el usuario');
}
}
async findAllProfesors(): Promise<User[]> {
try {
const profesors = await this.prisma.user.findMany({
where: {
role: UserRole.INSTRUCTOR,
}
})
return profesors
}
catch (error: any) {
this.logger.error('No pude listar los profesores', error);
throw new InternalServerErrorException('No pude listar los profesores');
}
}
async findByPhone(phone: string): Promise<User | null> {
try {
if (!phone) return null;
// Debe empezar con 549 (Argentina)
if (!phone.startsWith('549')) {
this.logger.error(`El número no empieza con 549 (Argentina): ${phone}`);
return null;
}
const normalized = phone.replace(/^549/, '');
return await this.prisma.user.findFirst({
where: { phone: { equals: normalized, mode: 'insensitive' } },
});
} catch (err) {
this.logger.error(`No pude encontrar usuario con teléfono ${phone}`, err);
return null;
}
}
async findByRfid(rfid: string): Promise<User | null> {
try {
// Primero intentar encontrar usuarios con teléfono
const userWithPhone = await this.prisma.user.findFirst({
where: {
rfid: { equals: rfid },
phone: { not: '' } // Que tengan teléfono (no vacío)
},
orderBy: { createdAt: 'desc' }
});
if (userWithPhone) {
return userWithPhone;
}
this.logger.log({
action: 'findByRfid',
message: `No se encontró usuario con RFID ${rfid} que tenga teléfono registrado.`
});
// Si no hay ninguno con teléfono, devolver cualquiera con ese RFID
return await this.prisma.user.findFirst({
where: { rfid: { equals: rfid } },
orderBy: { createdAt: 'desc' }
});
} catch (err) {
this.logger.error(`No pude encontro usuario con RFID ${rfid}`, err);
return null
}
}
async getUserAdmin(): Promise<User | null> {
try {
this.logger.log('Buscando usuario administrador...');
return await this.prisma.user.findFirst({
where: {
role: { equals: UserRole.ADMIN },
phone: { not: { in: [''] } }
},
});
} catch (err) {
this.logger.error('No pude encontrar el usuario administrador', err);
return null
//throw new InternalServerErrorException('No pude encontrar el usuario administrador');
}
}
//NOTE Crea un usuario temporal con RFID
async createUserTemporary(rfid: string): Promise<User> {
try {
const customId = await this.customIdService.generateCustomId('user', 'U')
const name = `Desconocido ${customId}`
return await this.prisma.user.create({
data: {
rfid,
customId,
name,
role: UserRole.GUEST,
},
});
}
catch (err: any) {
throw new InternalServerErrorException(`No pude crear el usuario temporal: ${err.message}`);
}
}
async findByCustomId(customId: string): Promise<User | null> {
try {
this.logger.log(`Buscando usuario por customId: ${customId}`);
return await this.prisma.user.findFirst({
where: {
customId: { equals: customId, mode: 'insensitive' } // Buscar por customId
}
});
} catch (err) {
this.logger.error(`No pude encontrar usuario con customId ${customId}`, err);
return null;
}
}
async deleteUser(customId: string): Promise<User> {
try {
const targetUser = await this.prisma.user.findFirst({
where: {
customId,
},
});
if (!targetUser) {
throw new InternalServerErrorException(`No se encontró un usuario con el customId: ${customId}`);
}
const updatedUser = await this.prisma.user.update({
where: {
id: targetUser.id,
},
data: {
deleted: new Date(), // Marcar como eliminado
},
})
return updatedUser
}
catch (error: any) {
this.logger.error(`No pude eliminar el usuario con customId ${customId}`, error);
throw new InternalServerErrorException(`No pude eliminar el usuario con customId ${customId}`);
}
}
//Necesito buscar el usuario que esta haciendo la accion , con el currentUserId , cuando el usuario sea instructor , solo puede modificar el nombre y el teléfono
async updateUserByCustomId(
userData: UserDataUpdate,
currentUserId?: string
): Promise<User> {
try {
/* 1️⃣ Obtener el usuario que está realizando la acción */
const currentUser = await this.prisma.user.findUnique({
where: { id: currentUserId },
});
if (!currentUser) {
throw new InternalServerErrorException(
`No se encontró el usuario que está realizando la acción (id: ${currentUserId})`
);
}
/* 2️⃣ Buscar el usuario objetivo por customId */
const targetUser = await this.prisma.user.findFirst({
where: {
customId: userData.customId,
},
});
this.logger.log(
`Actualizando usuario: ${targetUser?.name} por hash: ${userData.customId}`
);
if (!targetUser) {
throw new InternalServerErrorException(
`No se encontró un usuario con el hash: ${userData.customId}`
);
}
/* 3️⃣ Definir los campos que el usuario actual puede modificar */
const isInstructor =
currentUser.role === UserRole.INSTRUCTOR;
// Campos que cualquier usuario puede cambiar
const instructorAllowed = ['name', 'phone'];
// Si el que ejecuta la acción es instructor, restringimos a los
// campos permitidos; de lo contrario, dejamos que modifique todo
const allowedKeys: readonly string[] = isInstructor
? instructorAllowed
: Object.keys(userData);
/* 4️⃣ Validar que no se estén intentando actualizar campos no
permitidos (solo para instructors) */
const disallowed = Object.keys(userData).filter(
(k) => !allowedKeys.includes(k as keyof UserDataUpdate)
);
if (disallowed.length > 0) {
// Se lanza una excepción indicando falta de permisos
throw new ForbiddenException(
'no tienes permisos para actualizar estos campos'
);
}
/* 5️⃣ Construir el objeto de datos a actualizar */
const data: any = {};
for (const key of allowedKeys) {
const value = (userData as any)[key];
if (value !== undefined) {
data[key] = value;
}
}
if (Object.keys(data).length === 0) {
throw new InternalServerErrorException(
'No se proporcionaron campos válidos para actualizar'
);
}
/* 6️⃣ Ejecutar la actualización */
return await this.prisma.user.update({
where: { id: targetUser.id },
data,
});
} catch (err) {
this.logger.error('No pude actualizar el usuario por hash', err);
throw new InternalServerErrorException(
'No pude actualizar el usuario por hash'
);
}
}
async findAll(): Promise<User[]> {
try {
return await this.prisma.user.findMany();
} catch (err) {
// loggear o transformar el error
throw new InternalServerErrorException('No pude listar usuarios');
}
}
//TODO: implementar cuando se use IA
async findUserWithNaturalFilters(filters: UserFilter) {
const { nombre, birthdayMonth } = filters;
let query = `SELECT * FROM "User" WHERE 1=1`;
const params: any[] = [];
if (nombre) {
query += ` AND LOWER("name") LIKE LOWER($${params.length + 1})`;
params.push(`%${nombre}%`);
}
if (birthdayMonth) {
query += ` AND EXTRACT(MONTH FROM "birth") = $${params.length + 1}`;
params.push(birthdayMonth);
}
const users = await this.prisma.$queryRawUnsafe<User[]>(query, ...params);
return users;
}
//getInfoForProfessor
async getInfoForProfessor(professorId: string): Promise<any> {
}
async toogleActivateUser(id: string, body: { deleted: Date | null }): Promise<User> {
try {
const user = await this.prisma.user.update({
where: { id },
data: {
deleted: body.deleted,
},
});
return user;
} catch (error: any) {
this.logger.error(`No pude ${body.deleted ? 'activar' : 'desactivar'} el usuario con id ${id}`, error);
throw new InternalServerErrorException(`No se pudo ${body.deleted ? 'activar' : 'desactivar'} el usuario`);
}
}
/**
* Actualiza la foto de perfil de un usuario si es necesario
* @param userId ID del usuario
* @param profilePictureBase64 Foto en base64
* @param intervalDays Intervalo de días para actualizar (por defecto obtiene de config)
* @returns true si se actualizó, false si no era necesario
*/
async updateProfilePictureIfNeeded(
userId: string,
profilePictureBase64: string,
intervalDays?: number
): Promise<boolean> {
try {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { profilePictureUpdatedAt: true }
});
if (!user) {
this.logger.warn(`Usuario no encontrado para actualizar foto: ${userId}`);
return false;
}
const now = new Date();
const lastUpdate = user.profilePictureUpdatedAt;
// Si nunca se actualizó, actualizar
if (!lastUpdate) {
this.logger.log(`Primera vez actualizando foto de perfil para usuario: ${userId}`);
await this.prisma.user.update({
where: { id: userId },
data: {
profilePicture: profilePictureBase64,
profilePictureUpdatedAt: now
}
});
return true;
}
// Calcular días desde la última actualización
const daysSinceUpdate = Math.floor((now.getTime() - lastUpdate.getTime()) / (1000 * 60 * 60 * 24));
const daysInterval = intervalDays ?? 2; // Por defecto 2 días
// Si pasó el intervalo, actualizar
if (daysSinceUpdate >= daysInterval) {
this.logger.log(`Actualizando foto de perfil para usuario ${userId} (${daysSinceUpdate} días desde última actualización)`);
await this.prisma.user.update({
where: { id: userId },
data: {
profilePicture: profilePictureBase64,
profilePictureUpdatedAt: now
}
});
return true;
}
this.logger.debug(`No es necesario actualizar foto de perfil para usuario ${userId} (solo ${daysSinceUpdate} días desde última actualización)`);
return false;
} catch (error: any) {
this.logger.error(`Error actualizando foto de perfil para usuario ${userId}:`, error);
return false;
}
}
}
type ResponseInfoProfessor = {
//clases donde esta inscripto como profesor => Session[]
//horas trabajadas de este mes => number
//usuarios que asisten a sus clases => User[]
}
@@ -0,0 +1,50 @@
// src/whatsapp/flows/admin/adminFlows.ts
import { registerCreateSessionFlow } from './session/createSessionFlow';
import { SessionsService } from '../../../sessions/sessions.service';
import { UserService } from '../../../user/user.service';
// import { IaService } from '../../../ia/ia.service';
import { registerListSessionFlow } from './session/listSessionFlow';
import { registerListUserFlow } from './user/listUserFlow';
import { registerUserFlow } from './user/userFlow';
import { registerAdminMenu } from './menu/adminMenu';
import { registerSessionFlow } from './session/sessionFlow';
import { registerCreateSubscriptionFlow } from '../subscription/createSubscriptionFlow';
import { IaSubscriptionService } from '../../../ia/services/ia.subscription.service';
import { registerUpdateUserFlow } from './user/updateUserFlow';
import { registeDeleteUserFlow } from './user/deleteUser';
import { registerModifyUser } from './user/modifyUser';
import { AuthService } from '../../../auth/auth.service';
export const getAdminFlows = ({
userService,
sessionService,
iaService,
iaSubscription,
authService,
}: {
userService: UserService;
sessionService: SessionsService;
iaService: any;
iaSubscription: IaSubscriptionService;
authService: AuthService
}) => {
const subscriptionFlow = registerCreateSubscriptionFlow({ iaService: iaSubscription });
//Session
const createSession = registerCreateSessionFlow({ iaService, sessionService });
const listSession = registerListSessionFlow({ sessionService, iaService });
//user
const updateUser = registerUpdateUserFlow({ userService, iaService });
const deleteUser = registeDeleteUserFlow({ userService, iaService });
const listUser = registerListUserFlow({ iaService, userService });
const modifyUser = registerModifyUser({ deleteUser, updateUser });
//flows
const sessionFlow = registerSessionFlow({ sessionService, createSessionFlow: createSession, listSession, });
const userFlow = registerUserFlow({ listUser, modifyUser });
//main menu
const adminMenu = registerAdminMenu({ authService, userService })
return {
flows: [userFlow, modifyUser, createSession, deleteUser, updateUser, sessionFlow, listSession, listUser, adminMenu, subscriptionFlow],
references: { userFlow, modifyUser, createSession, sessionFlow, listSession, listUser, adminMenu, subscriptionFlow, deleteUser }
};
};
@@ -0,0 +1,67 @@
import { addKeyword, EVENTS, MemoryDB } from "@builderbot/bot";
// import { BaileysProvider } from "@builderbot/provider-baileys";
import { BaileysProvider as Provider } from 'aurik3-builderbot-baileys-custom';
import { exitFlow, validateOption } from "~/whatsapp/utils/exitFlow";
import { UserService } from "~/user/user.service";
import { AuthService, TokenRedirectURL } from "~/auth/auth.service";
type PropsRegisterAdminMenu = {
authService: AuthService;
userService: UserService;
}
export const registerAdminMenu = ({
authService,
userService,
}: PropsRegisterAdminMenu) => {
return addKeyword<Provider, MemoryDB>(EVENTS.ACTION)
/* ──────────────────────────────────────────────────────
* 1️⃣ Primer mensaje: saludo + lista de opciones
* ────────────────────────────────────────────────────── */
.addAction(async (ctx, { flowDynamic, endFlow, state }) => {
const user = await userService.findByPhone(ctx.from);
await flowDynamic(`👋 Hola admin ${user?.name}!`, { delay: 300 });
if (!user) { return endFlow(); }
await state.update({ user });
await flowDynamic(
`📚 ¿Qué quieres hacer?\n\n` +
`1️⃣ Ingresar a la web\n` +
`2️⃣ Modificar Clases\n` +
`3️⃣ Modificar Usuarios\n` +
`4️⃣ Configuración\n` +
`Escribe *1*, *2*, *3*, *4*, o *cancelar* para salir.`,
{ delay: 300 },
);
})
/* ──────────────────────────────────────────────────────
* 2️⃣ Captura de la respuesta del usuario
* ────────────────────────────────────────────────────── */
.addAction({ capture: true }, async ({ body }, { endFlow, fallBack, state }) => {
const input = body.trim().toLowerCase();
// Verificar si el usuario quiere cancelar
if (await exitFlow(input, endFlow, state)) {
return; // exitFlow ya manejó el endFlow
}
// Validar que la opción sea válida
if (!validateOption(input, ['1', '2', '3', '4'], fallBack)) {
return; // validateOption ya ejecutó el fallBack
}
const user = state.get("user");
const optionRedirect: Record<string, TokenRedirectURL> = {
"1": TokenRedirectURL.FRONT,
"2": TokenRedirectURL.SESSION,
"3": TokenRedirectURL.USER,
"4": TokenRedirectURL.CONFIG
};
const token = await authService.generarToken(user.id, optionRedirect[input]);
return endFlow(`🔗 Ingresá a la web con este link: ${process.env.URL_FRONT}?t=${token}`);
});
}
@@ -0,0 +1,113 @@
import { addKeyword, EVENTS, MemoryDB } from "@builderbot/bot";
// import { BaileysProvider } from "@builderbot/provider-baileys";
import { BaileysProvider as Provider } from 'aurik3-builderbot-baileys-custom';
import dayjs from "dayjs";
import 'dayjs/locale/es';
// import { IaService } from "../../../../ia/ia.service";
import { SessionsService, } from "../../../../sessions/sessions.service";
dayjs.locale('es');
export const registerCreateSessionFlow = ({ iaService, sessionService }: { iaService: any, sessionService: SessionsService }) => {
return addKeyword<Provider, MemoryDB>(EVENTS.ACTION)
// Primer paso: solicitar descripción
.addAction(async (_, { flowDynamic, state }) => {
await flowDynamic('🆕 Crear nueva sesión:\nPor favor, ingresa la descripción de la sesión:');
await flowDynamic('️ Ejemplos:\n*Recurrentes*: Clase "Zumba" los lunes a las 10:00AM - 11:00AM \n*Ocasionales*: Clase "Yoga" 30/6/2025 a las 10:00AM - 11:00AM');
})
// // Segundo paso: procesar mensaje del usuario con IA
// .addAction({ capture: true }, async (ctx, { state, flowDynamic, fallBack }) => {
// try {
// const input = ctx.body.trim().toLocaleLowerCase();
// if (input.length > 100) {
// return fallBack('El mensaje es demasiado largo. Intenta con una descripción más corta.');
// }
// if (input.includes('cancelar')) {
// state.clear();
// return flowDynamic('❌ Creacion cancelada.');
// }
// await flowDynamic('⏳ Procesando...');
// const response = await iaService.createSession(input);
// if (!response || !response.schedules || response.schedules.length === 0) {
// state.clear();
// return fallBack('❌ No se pudo generar la sesión. Intenta de nuevo.');
// }
// await state.update({ dataCreate: response });
// await flowDynamic(`🔍 Se creará una nueva clase:
// 📄 *Descripción*: ${response.description}
// 📅 *Tipo*: ${response.type}
// 🕒 *Horarios*:
// ${response.schedules.map((s) => {
// const diaSemana = s.dayOfWeek ? s.dayOfWeek + ' ' : '';
// const fecha = s.specificDate ? dayjs(s.specificDate).format('DD/MM/YYYY') + ' ' : '';
// const horaInicio = dayjs(s.startTime).format('HH:mm');
// const horaFin = dayjs(s.endTime).format('HH:mm');
// return `- ${diaSemana}${fecha}${horaInicio} - ${horaFin}`;
// }).join('\n')}
// *¿Es correcto?* Responde *si* o *no*.`);
// } catch (error) {
// console.error('Error al procesar el mensaje:', error);
// await state.clear();
// return fallBack('❌ Ocurrió un error inesperado. Intenta de nuevo.');
// }
// })
// // Tercer paso: confirmar creación de la sesión
// .addAction({ capture: true }, async (ctx, { state, flowDynamic, fallBack }) => {
// try {
// const input = ctx.body.trim().toLowerCase();
// if (input !== 'si' && input !== 'no') {
// return fallBack('❗ Responde exactamente *si* o *no*.');
// }
// // Si el usuario cancela
// if (input === 'no') {
// await state.clear();
// return flowDynamic('❌ Creacion cancelada.');
// }
// // Si el usuario confirma
// const sessionData: SessionWithSchedules = state.get('dataCreate');
// const upsertData: SessionWithSchedulesInput = {
// id: sessionData?.id,
// description: sessionData.description!,
// type: sessionData.type!,
// startDate: sessionData.startDate!,
// endDate: sessionData.endDate!,
// isActive: sessionData.isActive ?? true,
// schedules: sessionData.schedules?.map((schedule) => ({
// dayOfWeek: schedule.dayOfWeek ?? null,
// specificDate: schedule.specificDate ? new Date(schedule.specificDate) : null,
// startTime: new Date(schedule.startTime),
// endTime: new Date(schedule.endTime),
// isException: schedule.isException ?? false,
// })),
// };
// const session = await sessionService.upsertSession(upsertData);
// if (!session) {
// await state.clear();
// return fallBack('❌ No se pudo crear la sesión. Intenta de nuevo.');
// }
// await flowDynamic('✅ Clase creada exitosamente.\nPuedes verla en el menú de sesiones.');
// await state.clear();
// } catch (error) {
// console.error('Error al crear la sesión:', error);
// await state.clear();
// return fallBack('❌ Ocurrió un error al crear la clase. Intenta de nuevo.');
// }
// });
};
@@ -0,0 +1,48 @@
import { addKeyword, EVENTS, MemoryDB } from "@builderbot/bot";
import { BaileysProvider } from "@builderbot/provider-baileys";
import dayjs from "dayjs";
import 'dayjs/locale/es';
import { IaService } from "../../../../ia/ia.service";
import { SessionsService } from "../../../../sessions/sessions.service";
import { generateTimer } from "../../../utils/generateTimer";
dayjs.locale('es');
export const registerListSessionFlow = ({ iaService, sessionService }: { iaService: IaService, sessionService: SessionsService }) => {
return addKeyword<BaileysProvider, MemoryDB>(EVENTS.ACTION).addAnswer(`Buscando todas las clases:`)
.addAction(async (_, { flowDynamic }) => {
const clases = await sessionService.findAll();
if (!clases || clases.length === 0) {
await flowDynamic('❗ No hay clases registradas.');
return;
}
// Mapeamos cada sesión a una línea de salida
const lines = clases.map((clase) => {
// ID
const id = clase.customId;
// Descripción (hasta 40 chars)
let desc = clase.description!;
if (desc.length > 40) {
desc = desc.slice(0, 40) + '…';
}
// Horarios
const horarios = clase.schedules?.map((i) => {
const dia = i.dayOfWeek
? i.dayOfWeek
: dayjs(i.specificDate!).format('DD-MM');
const horaInicio = dayjs(i.startTime).format('HH:mm');
const horaFin = dayjs(i.endTime).format('HH:mm');
return `${dia} ${horaInicio}-${horaFin}`;
}).join(', ');
return `${id} | ${desc} | ${horarios}`;
});
// Enviamos todo en un solo mensaje
await flowDynamic( 'Estas son las clases registradas:' );
for (const line of lines) {
await flowDynamic([{ body: line.trim(), delay: generateTimer(150, 250) }]);
}
})
};
@@ -0,0 +1,110 @@
import { addKeyword, EVENTS, MemoryDB } from "@builderbot/bot";
// import { BaileysProvider } from "@builderbot/provider-baileys";
import { BaileysProvider as Provider } from 'aurik3-builderbot-baileys-custom';
import dayjs from "dayjs";
import 'dayjs/locale/es';
// import { IaService } from "../../../../ia/ia.service";
import { SessionFilter, SessionsService } from "../../../../sessions/sessions.service";
import { generateTimer } from "../../../utils/generateTimer";
import { buildPromptGetSession } from "../../../../ia/prompts/promptGetSession";
dayjs.locale('es');
export const registerListSessionFlow = ({ iaService, sessionService }: { iaService: any, sessionService: SessionsService }) => {
return addKeyword<Provider, MemoryDB>(EVENTS.ACTION)
.addAction(async (_, { flowDynamic }) => {
await flowDynamic(`🤔 ¿Que clases te ayudo a buscar?.\n`, { delay: 300 });
})
.addAction({ capture: true }, async ({ body }, { flowDynamic, fallBack }) => {
const input = body.trim().toLocaleLowerCase();
const prompt = buildPromptGetSession(input);
try {
const where = await iaService.executePrompt(prompt);
if (!where) {
await flowDynamic('❗ No se pudo interpretar la solicitud.');
return;
}
const inputWhere: SessionFilter = {
...where
}
await flowDynamic(`🔍 Buscando clases con los siguientes filtros:\n${JSON.stringify(inputWhere, null, 2)}`, { delay: 300 });
// const clases = await sessionService.findSessionWithNaturalFilters(where);
// if (!clases || clases.length === 0) {
// return fallBack('❗ No encontramos ninguna clase con esos valores, intenta denuevo');
// }
// // Mapeamos cada sesión a una línea de salida
// const lines = clases.map((clase) => {
// // ID
// const id = clase.customId;
// // Descripción (hasta 40 chars)
// let desc = clase.description!;
// if (desc.length > 40) {
// desc = desc.slice(0, 40) + '…';
// }
// // Horarios
// const horarios = clase.schedules?.map((i) => {
// const dia = i.dayOfWeek
// ? i.dayOfWeek
// : dayjs(i.specificDate!).format('DD-MM');
// const horaInicio = dayjs(i.startTime).format('HH:mm');
// const horaFin = dayjs(i.endTime).format('HH:mm');
// return `${dia} ${horaInicio}-${horaFin}`;
// }).join(', ');
// return `${id} | ${desc} | ${horarios}`;
// });
// // Enviamos todo en un solo mensaje
// await flowDynamic('Estas son las clases registradas:');
// for (const line of lines) {
// await flowDynamic([{ body: line.trim(), delay: generateTimer(150, 250) }]);
// }
}
catch (error) {
return fallBack('❗ Error al procesar la solicitud. Por favor, intenta de nuevo.');
}
})
// .addAction(async (_, { flowDynamic }) => {
// const clases = await sessionService.findAll();
// if (!clases || clases.length === 0) {
// await flowDynamic('❗ No hay clases registradas.');
// return;
// }
// // Mapeamos cada sesión a una línea de salida
// const lines = clases.map((clase) => {
// // ID
// const id = clase.customId;
// // Descripción (hasta 40 chars)
// let desc = clase.description!;
// if (desc.length > 40) {
// desc = desc.slice(0, 40) + '…';
// }
// // Horarios
// const horarios = clase.schedules?.map((i) => {
// const dia = i.dayOfWeek
// ? i.dayOfWeek
// : dayjs(i.specificDate!).format('DD-MM');
// const horaInicio = dayjs(i.startTime).format('HH:mm');
// const horaFin = dayjs(i.endTime).format('HH:mm');
// return `${dia} ${horaInicio}-${horaFin}`;
// }).join(', ');
// return `${id} | ${desc} | ${horarios}`;
// });
// // Enviamos todo en un solo mensaje
// await flowDynamic('Estas son las clases registradas:');
// for (const line of lines) {
// await flowDynamic([{ body: line.trim(), delay: generateTimer(150, 250) }]);
// }
// })
};

Some files were not shown because too many files have changed in this diff Show More