From aa2a0f1073086e5e69610e6fc275e0ee4be9ee26 Mon Sep 17 00:00:00 2001 From: miguel Date: Sun, 8 Mar 2026 19:19:30 -0600 Subject: [PATCH 1/2] =?UTF-8?q?Implementar=20carga=20de=20usuarios=20desde?= =?UTF-8?q?=20archivo=20Excel=20y=20validaci=C3=B3n=20de=20lista=20restrin?= =?UTF-8?q?gida=20para=20eventos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/alumnos/alumnos.controller.ts | 79 +++++++- src/alumnos/alumnos.module.ts | 6 +- src/alumnos/alumnos.service.ts | 103 +++++++++- src/app.module.ts | 2 + src/cuestionario/cuestionario.service.ts | 2 + .../dto/create-cuestionario.dto.ts | 9 + .../entities/cuestionario.entity.ts | 3 + .../cuestionario_respondido.module.ts | 2 + .../cuestionario_respondido.service.ts | 68 ++++++- src/db/typeorm.config.ts | 6 +- src/emails/registro-evento.ts | 13 +- src/evento/docs/evento.documentation.ts | 27 +++ src/evento/evento.controller.ts | 42 +++- src/evento/evento.module.ts | 8 +- .../entities/evento_usuario.entity.ts | 34 ++++ src/evento_usuario/evento_usuario.module.ts | 17 ++ src/evento_usuario/evento_usuario.service.ts | 189 ++++++++++++++++++ src/trabajadores/trabajadores.controller.ts | 10 +- src/trabajadores/trabajadores.module.ts | 2 + src/trabajadores/trabajadores.service.ts | 44 +++- 20 files changed, 628 insertions(+), 38 deletions(-) create mode 100644 src/evento_usuario/entities/evento_usuario.entity.ts create mode 100644 src/evento_usuario/evento_usuario.module.ts create mode 100644 src/evento_usuario/evento_usuario.service.ts diff --git a/src/alumnos/alumnos.controller.ts b/src/alumnos/alumnos.controller.ts index 44c406d..bc6169a 100644 --- a/src/alumnos/alumnos.controller.ts +++ b/src/alumnos/alumnos.controller.ts @@ -1,23 +1,92 @@ -import { Controller, Get, Param, NotFoundException } from '@nestjs/common'; +import { + Controller, + Get, + NotFoundException, + Param, + Post, + Query, + UnprocessableEntityException, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; import { AlumnosService, UsuarioData } from './alumnos.service'; -import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { + ApiBody, + ApiConsumes, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { AlumnoDto, ALUMNO_RESPONSES } from './alumnos.documentation'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +import { extname } from 'path'; @ApiTags('alumnos') @Controller('alumnos') export class AlumnosController { constructor(private readonly alumnosService: AlumnosService) {} + @Post('cargar') + @ApiOperation({ summary: 'Cargar archivo Excel con alumnos' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + description: 'Archivo Excel (.xlsx) con columnas: id_ncuenta, nombre, apellidos, carrera, genero', + }, + }, + required: ['file'], + }, + }) + @UseInterceptors( + FileInterceptor('file', { + storage: diskStorage({ + destination: './uploads', + filename: (_, file, callback) => { + const uniqueName = `${Date.now()}${extname(file.originalname)}`; + callback(null, uniqueName); + }, + }), + }), + ) + async cargarArchivo(@UploadedFile() file: Express.Multer.File) { + if (!file) { + throw new UnprocessableEntityException('No se ha subido ningún archivo.'); + } + return this.alumnosService.procesarArchivo(file.path); + } + @Get(':cuenta') @ApiOperation({ summary: 'Obtener datos de un alumno por su número de cuenta' }) @ApiParam({ name: 'cuenta', description: 'Número de cuenta del alumno' }) + @ApiQuery({ + name: 'id_evento', + required: false, + type: Number, + description: + 'ID del evento para validar si el alumno está en la lista restringida', + }) @ApiResponse(ALUMNO_RESPONSES[200]) @ApiResponse(ALUMNO_RESPONSES[404]) @ApiResponse(ALUMNO_RESPONSES[500]) - async getAlumnoByCuenta(@Param('cuenta') cuenta: string): Promise { - const alumno = await this.alumnosService.findByCuenta(cuenta); + async getAlumnoByCuenta( + @Param('cuenta') cuenta: string, + @Query('id_evento') idEventoRaw?: string, + ): Promise { + const id_evento = + idEventoRaw !== undefined ? parseInt(idEventoRaw, 10) : undefined; + const alumno = await this.alumnosService.findByCuenta(cuenta, id_evento); if (!alumno) { - throw new NotFoundException(`No se encontró alumno con número de cuenta ${cuenta}`); + throw new NotFoundException( + `No se encontró alumno con número de cuenta ${cuenta}`, + ); } return alumno; } diff --git a/src/alumnos/alumnos.module.ts b/src/alumnos/alumnos.module.ts index ff25a76..605eade 100644 --- a/src/alumnos/alumnos.module.ts +++ b/src/alumnos/alumnos.module.ts @@ -3,9 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AlumnosController } from './alumnos.controller'; import { AlumnosService } from './alumnos.service'; import { RegistroAlumno } from './entities/registro-alumno.entity'; +import { EventoUsuarioModule } from '../evento_usuario/evento_usuario.module'; @Module({ - imports: [TypeOrmModule.forFeature([RegistroAlumno], 'alumnosConnection')], + imports: [ + TypeOrmModule.forFeature([RegistroAlumno], 'alumnosConnection'), + EventoUsuarioModule, + ], controllers: [AlumnosController], providers: [AlumnosService], exports: [AlumnosService], diff --git a/src/alumnos/alumnos.service.ts b/src/alumnos/alumnos.service.ts index bef0f1a..e5f9288 100644 --- a/src/alumnos/alumnos.service.ts +++ b/src/alumnos/alumnos.service.ts @@ -1,7 +1,14 @@ -import { Injectable } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { RegistroAlumno } from './entities/registro-alumno.entity'; +import { EventoUsuarioService } from '../evento_usuario/evento_usuario.service'; +import * as fs from 'fs'; +import * as XLSX from 'xlsx'; export type UsuarioData = { cuenta: string | null; @@ -10,26 +17,102 @@ export type UsuarioData = { carrera: string | null; genero: string | null; rfc?: string | null; // Solo para trabajadores -} +}; @Injectable() export class AlumnosService { constructor( @InjectRepository(RegistroAlumno, 'alumnosConnection') private registroAlumnoRepository: Repository, + private readonly eventoUsuarioService: EventoUsuarioService, ) {} - async findByCuenta(cuenta: string): Promise { + async procesarArchivo( + filePath: string, + ): Promise<{ insertados: number; omitidos: number }> { + const workbook = XLSX.readFile(filePath); + const sheetName = workbook.SheetNames[0]; + const data: any[] = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]); + console.log(filePath); + + let insertados = 0; + let omitidos = 0; + + for (const row of data) { + console.log(row); + const cuentaRaw = row['id_ncuenta']; + const cuenta = Number.isFinite(Number(cuentaRaw)) + ? Number(cuentaRaw) + : null; + + if (!cuenta) { + omitidos++; + continue; + } + + const existe = await this.registroAlumnoRepository.findOne({ + where: { id_ncuenta: cuenta }, + }); + + if (existe) { + omitidos++; + continue; + } + + const nuevo = this.registroAlumnoRepository.create({ + id_ncuenta: cuenta, + nombre: (row['nombre'] || '').trim(), + apellidos: (row['apellidos'] || '').trim(), + carrera: (row['carrera'] || '').trim(), + genero: (row['genero'] || '').trim().substring(0, 1).toUpperCase(), + }); + + await this.registroAlumnoRepository.save(nuevo); + insertados++; + } + + fs.unlinkSync(filePath); + + return { insertados, omitidos }; + } + + async findByCuenta( + cuenta: string, + id_evento?: number, + ): Promise { const alumno = await this.registroAlumnoRepository.findOne({ where: { id_ncuenta: parseInt(cuenta, 10) }, }); - - return { - cuenta: alumno?.id_ncuenta.toString() || null, - nombre: alumno?.nombre || null, - apellidos: alumno?.apellidos || null, - carrera: alumno?.carrera || null, - genero: alumno?.genero || null, + + if (!alumno) { + throw new NotFoundException( + `No se encontró alumno con número de cuenta ${cuenta}`, + ); } + + if (id_evento !== undefined) { + const tieneLista = + await this.eventoUsuarioService.eventoTieneListaRestringida(id_evento); + + if (tieneLista) { + const enLista = await this.eventoUsuarioService.identificadorEnLista( + id_evento, + alumno.id_ncuenta.toString(), + ); + if (!enLista) { + throw new ForbiddenException( + `El alumno con cuenta ${cuenta} no está en la lista de usuarios habilitados para este evento.`, + ); + } + } + } + + return { + cuenta: alumno.id_ncuenta.toString(), + nombre: alumno.nombre, + apellidos: alumno.apellidos, + carrera: alumno.carrera, + genero: alumno.genero, + }; } } diff --git a/src/app.module.ts b/src/app.module.ts index 471d82b..89100f1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,6 +29,7 @@ import { ValidacionesModule } from './validaciones/validaciones.module'; import { AlumnosModule } from './alumnos/alumnos.module'; import { TrabajadoresModule } from './trabajadores/trabajadores.module'; import { TipoEventoModule } from './tipo_evento/tipo_evento.module'; +import { EventoUsuarioModule } from './evento_usuario/evento_usuario.module'; @Module({ imports: [ @@ -69,6 +70,7 @@ import { TipoEventoModule } from './tipo_evento/tipo_evento.module'; ValidacionesModule, QrModule, TrabajadoresModule, + EventoUsuarioModule, ], controllers: [AppController], diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index b5f9ef7..1db78b7 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -257,6 +257,7 @@ export class CuestionarioService { cuestionario.editable = true; cuestionario.id_evento = createCuestionarioDto.id_evento; cuestionario.cupo_maximo = createCuestionarioDto.cupo_maximo; + cuestionario.mensaje_correo = createCuestionarioDto.mensaje_correo; const savedCuestionario = await queryRunner.manager.save(cuestionario); @@ -419,6 +420,7 @@ export class CuestionarioService { editable: true, id_evento: eventoExistente.id_evento, // Asociar el evento creado cupo_maximo: cuestionario.cupo_maximo, + mensaje_correo: cuestionario.mensaje_correo, }); const savedCuestionario = await queryRunner.manager.save(cuestionarioEntity); diff --git a/src/cuestionario/dto/create-cuestionario.dto.ts b/src/cuestionario/dto/create-cuestionario.dto.ts index 0cb2f34..53be4f6 100644 --- a/src/cuestionario/dto/create-cuestionario.dto.ts +++ b/src/cuestionario/dto/create-cuestionario.dto.ts @@ -89,6 +89,15 @@ export class CreateCuestionarioDto { @IsString() evento?: string; + @ApiProperty({ + description: 'Mensaje personalizado que se incluye en el correo de confirmación de registro', + example: 'Favor de presentar este código QR en la entrada del evento.', + required: false, + }) + @IsOptional() + @IsString() + mensaje_correo?: string; + @ApiProperty({ description: 'Secciones del cuestionario', type: [CreateSeccionDto], diff --git a/src/cuestionario/entities/cuestionario.entity.ts b/src/cuestionario/entities/cuestionario.entity.ts index c4e569e..0980dca 100644 --- a/src/cuestionario/entities/cuestionario.entity.ts +++ b/src/cuestionario/entities/cuestionario.entity.ts @@ -51,6 +51,9 @@ export class Cuestionario { @Column({ type: 'int' }) id_evento: number; + @Column({ type: 'text', nullable: true }) + mensaje_correo?: string; + // Relaciones @ManyToOne(() => Evento, (evento) => evento.cuestionarios, { nullable: true }) @JoinColumn({ name: 'id_evento' }) diff --git a/src/cuestionario_respondido/cuestionario_respondido.module.ts b/src/cuestionario_respondido/cuestionario_respondido.module.ts index e006f18..6e37158 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.module.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.module.ts @@ -14,6 +14,7 @@ import { RespuestaParticipanteCerrada } from '../respuesta_participante_cerrada/ import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity'; import { Opcion } from '../opcion/entities/opcion.entity'; import { ValidacionesModule } from '../validaciones/validaciones.module'; +import { EventoUsuarioModule } from '../evento_usuario/evento_usuario.module'; @Module({ imports: [ @@ -31,6 +32,7 @@ import { ValidacionesModule } from '../validaciones/validaciones.module'; ParticipanteModule, ValidacionesModule, QrModule, + EventoUsuarioModule, ], controllers: [CuestionarioRespondidoController], providers: [CuestionarioRespondidoService], diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index b2b74ad..caa4a81 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -4,7 +4,7 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DataSource } from 'typeorm'; +import { Repository, DataSource, In } from 'typeorm'; import { CreateCuestionarioRespondidoDto } from './dto/create-cuestionario_respondido.dto'; import { UpdateCuestionarioRespondidoDto } from './dto/update-cuestionario_respondido.dto'; import { SubmitRespuestasDto } from './dto/submit-respuestas.dto'; @@ -23,6 +23,9 @@ import * as QRCode from 'qrcode'; import { CuestionarioService } from 'src/cuestionario/cuestionario.service'; import { generarHtmlCorreoAsistencia } from 'src/emails/registro-evento'; import { TipoPreguntaEnum } from 'src/tipo_pregunta/entities/tipo_pregunta.entity'; +import { EventoUsuarioService } from '../evento_usuario/evento_usuario.service'; +import { TiposValidacion } from '../pregunta/entities/pregunta.entity'; + @Injectable() export class CuestionarioRespondidoService { constructor( @@ -41,6 +44,7 @@ export class CuestionarioRespondidoService { private validadorRespuestasService: ValidadorRespuestasService, private cuestionarioService: CuestionarioService, private qrTokenService: QrTokenService, + private eventoUsuarioService: EventoUsuarioService, ) {} create(createCuestionarioRespondidoDto: CreateCuestionarioRespondidoDto) { @@ -79,6 +83,66 @@ export class CuestionarioRespondidoService { } } + // 1.2 Validar restricción de lista de usuarios del evento + if (cuestionario.evento?.id_evento) { + const id_evento = cuestionario.evento.id_evento; + const tieneLista = + await this.eventoUsuarioService.eventoTieneListaRestringida(id_evento); + + if (tieneLista) { + const preguntaIds = submitDto.respuestas.map((r) => r.id_pregunta); + const identityPreguntas = await this.preguntaRepository.find({ + where: [ + { + id_pregunta: In(preguntaIds), + validacion: TiposValidacion.CUENTA_ALUMNO, + }, + { + id_pregunta: In(preguntaIds), + validacion: TiposValidacion.RFC, + }, + ], + }); + + let identificador: string | null = null; + for (const pregunta of identityPreguntas) { + const respuesta = submitDto.respuestas.find( + (r) => r.id_pregunta === pregunta.id_pregunta, + ); + if ( + respuesta && + typeof respuesta.valor === 'string' && + respuesta.valor.trim() + ) { + identificador = respuesta.valor.trim().toUpperCase(); + break; + } + } + + if (!identificador) { + await queryRunner.rollbackTransaction(); + return { + success: false, + message: + 'Este evento requiere que estés en la lista de usuarios habilitados. No se encontró un número de cuenta o RFC válido en las respuestas enviadas.', + }; + } + + const enLista = await this.eventoUsuarioService.identificadorEnLista( + id_evento, + identificador, + ); + + if (!enLista) { + await queryRunner.rollbackTransaction(); + return { + success: false, + message: `El usuario con identificador ${identificador} no está en la lista de usuarios habilitados para este evento.`, + }; + } + } + } + // 2. Buscar o crear participante let participante = await this.participanteRepository.findOne({ where: { correo: submitDto.correo }, @@ -146,6 +210,7 @@ export class CuestionarioRespondidoService { fechaFinEvento: cuestionario.evento?.fecha_fin ? new Date(cuestionario.evento.fecha_fin).toLocaleString() : undefined, + mensajePersonalizado: cuestionario.mensaje_correo, }), adjuntos: [ { @@ -374,6 +439,7 @@ export class CuestionarioRespondidoService { fechaFinEvento: cuestionario.evento?.fecha_fin ? new Date(cuestionario.evento.fecha_fin).toLocaleString() : undefined, + mensajePersonalizado: cuestionario.mensaje_correo, }), adjuntos: [ { diff --git a/src/db/typeorm.config.ts b/src/db/typeorm.config.ts index 950c71e..7be22c6 100644 --- a/src/db/typeorm.config.ts +++ b/src/db/typeorm.config.ts @@ -21,6 +21,7 @@ import { TipoCuestionario } from 'src/tipo_cuestionario/entities/tipo_cuestionar import { TipoPregunta } from 'src/tipo_pregunta/entities/tipo_pregunta.entity'; import { TipoUser } from 'src/tipo_user/entities/tipo_user.entity'; import { TipoEvento } from 'src/tipo_evento/entities/tipo_evento.entity'; +import { EventoUsuario } from 'src/evento_usuario/entities/evento_usuario.entity'; export const createMainDbConfig = async ( configService: ConfigService, @@ -51,13 +52,13 @@ export const createMainDbConfig = async ( TipoEvento, TipoPregunta, TipoUser, + EventoUsuario, ], retryAttempts: 5, retryDelay: 3000, connectTimeout: 30000, synchronize: configService.get('SYNCHRONIZE') === 'true', - dropSchema: configService.get('DROP_SCHEMA') === 'true', }); export const createAlumnosDbConfig = async ( @@ -75,6 +76,5 @@ export const createAlumnosDbConfig = async ( retryDelay: 3000, connectTimeout: 30000, - synchronize: false, - dropSchema: false, + synchronize: true, }); diff --git a/src/emails/registro-evento.ts b/src/emails/registro-evento.ts index cc776d8..d20a292 100644 --- a/src/emails/registro-evento.ts +++ b/src/emails/registro-evento.ts @@ -1,10 +1,17 @@ -const message = { +const messagePorTipoEvento: Record = { 'Cultural / Artístico': 'Favor de presentar este código QR (en tu celular o impreso) en la mesa de atención de la Sala Emma Rizo 10 minutos antes de la hora de acceso reservada.', 'Feria / Expo': 'Favor de presentar el código QR en cualquier stand de la feria, para recibir tu constancia de asistencia.', }; +const mensajeDefault = + 'Favor de presentar este código QR (en tu celular o impreso) en las mesas de juegos el día del evento, para participar por tu premio.'; + +export function getMensajeDefaultPorTipoEvento(tipoEvento: string): string { + return messagePorTipoEvento[tipoEvento] ?? mensajeDefault; +} + export function generarHtmlCorreoAsistencia({ nombreForm, nombreEvento, @@ -13,6 +20,7 @@ export function generarHtmlCorreoAsistencia({ fechaInicioEvento, fechaFinEvento, tipoEvento, + mensajePersonalizado, }: { nombreForm: string; nombreEvento: string; @@ -21,6 +29,7 @@ export function generarHtmlCorreoAsistencia({ fechaRegistro: string; fechaInicioEvento?: string; fechaFinEvento?: string; + mensajePersonalizado?: string; }) { const horarioEvento = fechaInicioEvento && fechaFinEvento @@ -43,7 +52,7 @@ export function generarHtmlCorreoAsistencia({

- ${message[tipoEvento] ? message[tipoEvento] : 'Favor de presentar este código QR (en tu celular o impreso) en las mesas de juegos el día del evento, para participar por tu premio.'} + ${mensajePersonalizado ?? getMensajeDefaultPorTipoEvento(tipoEvento)}

diff --git a/src/evento/docs/evento.documentation.ts b/src/evento/docs/evento.documentation.ts index f1a9b1b..4621482 100644 --- a/src/evento/docs/evento.documentation.ts +++ b/src/evento/docs/evento.documentation.ts @@ -42,6 +42,33 @@ export class EventoApiDocumentation { }), ); + static ApiCargarUsuarios = applyDecorators( + ApiOperation({ + summary: + 'Cargar lista de usuarios (alumnos y/o trabajadores) para un evento desde un archivo Excel', + }), + ApiParam({ + name: 'id', + type: Number, + description: 'ID del evento al que se carga la lista', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + description: + 'Archivo Excel (.xlsx) con columna "cuenta" para alumnos y/o columna "rfc" para trabajadores', + }, + }, + required: ['file'], + }, + }), + ); + static ApiPostBanner = applyDecorators( ApiOperation({ summary: 'Subir banner para un evento' }), ApiParam({ diff --git a/src/evento/evento.controller.ts b/src/evento/evento.controller.ts index c84c896..75619d9 100644 --- a/src/evento/evento.controller.ts +++ b/src/evento/evento.controller.ts @@ -19,11 +19,15 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { diskStorage } from 'multer'; import { extname } from 'path'; import { EventoApiDocumentation } from './docs/evento.documentation'; +import { EventoUsuarioService } from '../evento_usuario/evento_usuario.service'; @Controller('evento') @EventoApiDocumentation.ApiController export class EventoController { - constructor(private eventoService: EventoService) {} + constructor( + private eventoService: EventoService, + private eventoUsuarioService: EventoUsuarioService, + ) {} @Post() @EventoApiDocumentation.ApiCreate @@ -64,6 +68,42 @@ export class EventoController { return this.eventoService.getCuestionarioEvento(id_evento, id_cuestionario); } + @Post(':id/usuarios') + @EventoApiDocumentation.ApiCargarUsuarios + @UseInterceptors( + FileInterceptor('file', { + storage: diskStorage({ + destination: './uploads', + filename: (_, file, callback) => { + const uniqueName = `${Date.now()}${extname(file.originalname)}`; + callback(null, uniqueName); + }, + }), + fileFilter: (_, file, callback) => { + const ext = extname(file.originalname).toLowerCase(); + if (ext === '.xlsx' || ext === '.xls') { + callback(null, true); + } else { + callback( + new Error('Solo se permiten archivos Excel (.xlsx, .xls)'), + false, + ); + } + }, + }), + ) + async cargarUsuarios( + @Param('id', ParseIntPipe) id: number, + @UploadedFile() file: Express.Multer.File, + ) { + if (!file) { + throw new UnprocessableEntityException( + 'No se ha subido ningún archivo o el archivo no es válido.', + ); + } + return this.eventoUsuarioService.cargaUsuariosDesdeXlsx(id, file.path); + } + @Post(':id/banner') @UseInterceptors( FileInterceptor('banner', { diff --git a/src/evento/evento.module.ts b/src/evento/evento.module.ts index 9335a40..f88002c 100644 --- a/src/evento/evento.module.ts +++ b/src/evento/evento.module.ts @@ -3,11 +3,15 @@ import { EventoService } from './evento.service'; import { EventoController } from './evento.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Evento } from './entities/evento.entity'; +import { EventoUsuarioModule } from '../evento_usuario/evento_usuario.module'; @Module({ - imports: [TypeOrmModule.forFeature([Evento])], + imports: [ + TypeOrmModule.forFeature([Evento]), + EventoUsuarioModule, + ], controllers: [EventoController], providers: [EventoService], - exports: [EventoService] + exports: [EventoService], }) export class EventoModule {} diff --git a/src/evento_usuario/entities/evento_usuario.entity.ts b/src/evento_usuario/entities/evento_usuario.entity.ts new file mode 100644 index 0000000..2e5cfda --- /dev/null +++ b/src/evento_usuario/entities/evento_usuario.entity.ts @@ -0,0 +1,34 @@ +import { Evento } from 'src/evento/entities/evento.entity'; +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; + +export enum TipoUsuarioEnum { + ALUMNO = 'alumno', + TRABAJADOR = 'trabajador', +} + +@Entity('evento_usuario') +@Index(['id_evento', 'tipo_usuario', 'identificador'], { unique: true }) +export class EventoUsuario { + @PrimaryGeneratedColumn() + id_evento_usuario: number; + + @Column() + id_evento: number; + + @ManyToOne(() => Evento, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'id_evento' }) + evento: Evento; + + @Column({ type: 'enum', enum: TipoUsuarioEnum }) + tipo_usuario: TipoUsuarioEnum; + + @Column({ type: 'varchar', length: 20 }) + identificador: string; +} diff --git a/src/evento_usuario/evento_usuario.module.ts b/src/evento_usuario/evento_usuario.module.ts new file mode 100644 index 0000000..e81a846 --- /dev/null +++ b/src/evento_usuario/evento_usuario.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { EventoUsuario } from './entities/evento_usuario.entity'; +import { EventoUsuarioService } from './evento_usuario.service'; +import { RegistroAlumno } from '../alumnos/entities/registro-alumno.entity'; +import { RegistroTrabajador } from '../trabajadores/entities/trabajadore.entity'; +import { Evento } from '../evento/entities/evento.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([EventoUsuario, Evento]), + TypeOrmModule.forFeature([RegistroAlumno, RegistroTrabajador], 'alumnosConnection'), + ], + providers: [EventoUsuarioService], + exports: [EventoUsuarioService], +}) +export class EventoUsuarioModule {} diff --git a/src/evento_usuario/evento_usuario.service.ts b/src/evento_usuario/evento_usuario.service.ts new file mode 100644 index 0000000..e5d7360 --- /dev/null +++ b/src/evento_usuario/evento_usuario.service.ts @@ -0,0 +1,189 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + EventoUsuario, + TipoUsuarioEnum, +} from './entities/evento_usuario.entity'; +import { RegistroAlumno } from '../alumnos/entities/registro-alumno.entity'; +import { RegistroTrabajador } from '../trabajadores/entities/trabajadore.entity'; +import { Evento } from '../evento/entities/evento.entity'; +import * as fs from 'fs'; +import * as XLSX from 'xlsx'; + +@Injectable() +export class EventoUsuarioService { + constructor( + @InjectRepository(EventoUsuario) + private readonly eventoUsuarioRepo: Repository, + @InjectRepository(RegistroAlumno, 'alumnosConnection') + private readonly alumnoRepo: Repository, + @InjectRepository(RegistroTrabajador, 'alumnosConnection') + private readonly trabajadorRepo: Repository, + @InjectRepository(Evento) + private readonly eventoRepo: Repository, + ) {} + + async eventoTieneListaRestringida(id_evento: number): Promise { + const count = await this.eventoUsuarioRepo.count({ where: { id_evento } }); + return count > 0; + } + + async identificadorEnLista( + id_evento: number, + identificador: string, + ): Promise { + const row = await this.eventoUsuarioRepo.findOne({ + where: { + id_evento, + identificador: identificador.trim().toUpperCase(), + }, + }); + return !!row; + } + + async bulkInsert( + id_evento: number, + alumnos: string[], + trabajadores: string[], + ): Promise<{ insertados: number; omitidos: number; errores: string[] }> { + console.log(id_evento, alumnos, trabajadores); + let insertados = 0; + let omitidos = 0; + const errores: string[] = []; + + for (const cuenta of alumnos) { + try { + const result = await this.eventoUsuarioRepo + .createQueryBuilder() + .insert() + .into(EventoUsuario) + .values({ + id_evento, + tipo_usuario: TipoUsuarioEnum.ALUMNO, + identificador: cuenta.toUpperCase(), + }) + .orIgnore() + .execute(); + if (result.raw?.affectedRows > 0) { + insertados++; + } else { + omitidos++; + } + } catch { + omitidos++; + errores.push(`Error al insertar cuenta ${cuenta}`); + } + } + + for (const rfc of trabajadores) { + try { + const result = await this.eventoUsuarioRepo + .createQueryBuilder() + .insert() + .into(EventoUsuario) + .values({ + id_evento, + tipo_usuario: TipoUsuarioEnum.TRABAJADOR, + identificador: rfc.toUpperCase(), + }) + .orIgnore() + .execute(); + if (result.raw?.affectedRows > 0) { + insertados++; + } else { + omitidos++; + } + } catch { + omitidos++; + errores.push(`Error al insertar RFC ${rfc}`); + } + } + + return { insertados, omitidos, errores }; + } + + async cargaUsuariosDesdeXlsx( + id_evento: number, + filePath: string, + ): Promise<{ insertados: number; omitidos: number; errores: string[] }> { + const evento = await this.eventoRepo.findOne({ where: { id_evento } }); + if (!evento) { + fs.unlinkSync(filePath); + throw new NotFoundException(`Evento con ID ${id_evento} no encontrado.`); + } + + const workbook = XLSX.readFile(filePath); + const sheetName = workbook.SheetNames[0]; + const data: any[] = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]); + + const alumnosValidos: string[] = []; + const trabajadoresValidos: string[] = []; + let omitidos = 0; + const errores: string[] = []; + + for (const row of data) { + const cuentaRaw = row['cuenta']; + const rfcRaw = row['rfc']; + + if (cuentaRaw !== undefined && cuentaRaw !== null && cuentaRaw !== '') { + const cuenta = String(cuentaRaw).trim(); + const alumno = await this.alumnoRepo.findOne({ + where: { id_ncuenta: parseInt(cuenta, 10) }, + }); + console.log('Alumno encontrado:', alumno); + if (!alumno) { + omitidos++; + errores.push(`Cuenta no encontrada en el sistema: ${cuenta}`); + continue; + } + alumnosValidos.push(alumno.id_ncuenta.toString()); + continue; + } + + if (rfcRaw !== undefined && rfcRaw !== null && rfcRaw !== '') { + const rfc = String(rfcRaw).trim().toUpperCase(); + const trabajador = await this.trabajadorRepo.findOne({ + where: { rfc }, + }); + if (!trabajador) { + omitidos++; + errores.push(`RFC no encontrado en el sistema: ${rfc}`); + continue; + } + trabajadoresValidos.push(trabajador.rfc); + continue; + } + + omitidos++; + } + + fs.unlinkSync(filePath); + + const resultado = await this.bulkInsert( + id_evento, + alumnosValidos, + trabajadoresValidos, + ); + + return { + insertados: resultado.insertados, + omitidos: omitidos + resultado.omitidos, + errores: [...errores, ...resultado.errores], + }; + } + + async getUsuariosEvento( + id_evento: number, + ): Promise<{ alumnos: string[]; trabajadores: string[] }> { + const rows = await this.eventoUsuarioRepo.find({ where: { id_evento } }); + return { + alumnos: rows + .filter((r) => r.tipo_usuario === TipoUsuarioEnum.ALUMNO) + .map((r) => r.identificador), + trabajadores: rows + .filter((r) => r.tipo_usuario === TipoUsuarioEnum.TRABAJADOR) + .map((r) => r.identificador), + }; + } +} diff --git a/src/trabajadores/trabajadores.controller.ts b/src/trabajadores/trabajadores.controller.ts index a10fe84..fd60f76 100644 --- a/src/trabajadores/trabajadores.controller.ts +++ b/src/trabajadores/trabajadores.controller.ts @@ -9,6 +9,7 @@ import { UseInterceptors, UploadedFile, NotFoundException, + Query, } from '@nestjs/common'; import { TrabajadoresService } from './trabajadores.service'; import { CreateTrabajadoreDto } from './dto/create-trabajadore.dto'; @@ -46,8 +47,13 @@ export class TrabajadoresController { } @Get(':rfc') - findByRfc(@Param('rfc') rfc: string) { - const trabajador = this.trabajadoresService.findByRfc(rfc); + async findByRfc( + @Param('rfc') rfc: string, + @Query('id_evento') idEventoRaw?: string, + ) { + const id_evento = + idEventoRaw !== undefined ? parseInt(idEventoRaw, 10) : undefined; + const trabajador = await this.trabajadoresService.findByRfc(rfc, id_evento); if (!trabajador) { throw new NotFoundException(`No se encontró trabajador con RFC ${rfc}`); } diff --git a/src/trabajadores/trabajadores.module.ts b/src/trabajadores/trabajadores.module.ts index d9f8855..c82072c 100644 --- a/src/trabajadores/trabajadores.module.ts +++ b/src/trabajadores/trabajadores.module.ts @@ -3,10 +3,12 @@ import { TrabajadoresService } from './trabajadores.service'; import { TrabajadoresController } from './trabajadores.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { RegistroTrabajador } from './entities/trabajadore.entity'; +import { EventoUsuarioModule } from '../evento_usuario/evento_usuario.module'; @Module({ imports: [ TypeOrmModule.forFeature([RegistroTrabajador], 'alumnosConnection'), + EventoUsuarioModule, ], controllers: [TrabajadoresController], providers: [TrabajadoresService], diff --git a/src/trabajadores/trabajadores.service.ts b/src/trabajadores/trabajadores.service.ts index 4aa8355..4da3211 100644 --- a/src/trabajadores/trabajadores.service.ts +++ b/src/trabajadores/trabajadores.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { ForbiddenException, Injectable } from '@nestjs/common'; import { CreateTrabajadoreDto } from './dto/create-trabajadore.dto'; import { UpdateTrabajadoreDto } from './dto/update-trabajadore.dto'; import { InjectRepository } from '@nestjs/typeorm'; @@ -7,12 +7,14 @@ import { Repository } from 'typeorm'; import * as fs from 'fs'; import * as XLSX from 'xlsx'; import { UsuarioData } from 'src/alumnos/alumnos.service'; +import { EventoUsuarioService } from '../evento_usuario/evento_usuario.service'; @Injectable() export class TrabajadoresService { constructor( @InjectRepository(RegistroTrabajador, 'alumnosConnection') private readonly trabajadorRepo: Repository, + private readonly eventoUsuarioService: EventoUsuarioService, ) {} async procesarArchivo( @@ -69,21 +71,41 @@ export class TrabajadoresService { return { insertados, omitidos }; } - async findByRfc(rfc: string): Promise { + async findByRfc( + rfc: string, + id_evento?: number, + ): Promise { const trabajador = await this.trabajadorRepo.findOne({ where: { rfc: rfc.trim().toUpperCase() }, }); - return { - cuenta: trabajador?.num_trabajador?.toString() || null, - nombre: trabajador?.nombre || null, - apellidos: trabajador - ? `${trabajador.apellidos}`.trim() - : null, - carrera: trabajador?.carrera || null, - genero: trabajador?.genero || null, - rfc: trabajador?.rfc || null, // Incluye RFC en el retorno + if (!trabajador) return null; + + if (id_evento !== undefined) { + const tieneLista = + await this.eventoUsuarioService.eventoTieneListaRestringida(id_evento); + + if (tieneLista) { + const enLista = await this.eventoUsuarioService.identificadorEnLista( + id_evento, + trabajador.rfc, + ); + if (!enLista) { + throw new ForbiddenException( + `El trabajador con RFC ${rfc} no está en la lista de usuarios habilitados para este evento.`, + ); + } + } } + + return { + cuenta: trabajador.num_trabajador?.toString() || null, + nombre: trabajador.nombre || null, + apellidos: `${trabajador.apellidos}`.trim() || null, + carrera: trabajador.carrera || null, + genero: trabajador.genero || null, + rfc: trabajador.rfc || null, + }; } create(createTrabajadoreDto: CreateTrabajadoreDto) { From f4bd054aa15b3aecd542c3bdfdec9adfbb63ebb3 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 9 Mar 2026 19:13:26 -0600 Subject: [PATCH 2/2] Refactor respuesta validation in submitRespuestas method for improved clarity --- .../cuestionario_respondido.service.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index caa4a81..e7c5c19 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -87,7 +87,9 @@ export class CuestionarioRespondidoService { if (cuestionario.evento?.id_evento) { const id_evento = cuestionario.evento.id_evento; const tieneLista = - await this.eventoUsuarioService.eventoTieneListaRestringida(id_evento); + await this.eventoUsuarioService.eventoTieneListaRestringida( + id_evento, + ); if (tieneLista) { const preguntaIds = submitDto.respuestas.map((r) => r.id_pregunta); @@ -109,13 +111,17 @@ export class CuestionarioRespondidoService { const respuesta = submitDto.respuestas.find( (r) => r.id_pregunta === pregunta.id_pregunta, ); + if ( respuesta && - typeof respuesta.valor === 'string' && - respuesta.valor.trim() + respuesta.valor !== null && + respuesta.valor !== undefined ) { - identificador = respuesta.valor.trim().toUpperCase(); - break; + const valorStr = String(respuesta.valor).trim(); + if (valorStr) { + identificador = valorStr.toUpperCase(); + break; + } } }