58 Commits

Author SHA1 Message Date
miguel 897eaf619b correct bug no exp
Co-authored-by: Copilot <copilot@github.com>
2026-05-14 19:12:01 -06:00
miguel d6da4d093b Merge branch 'develop' 2026-05-14 19:09:48 -06:00
miguel 9ccd08acc6 Add contador field to EventoUsuario and update related services for handling user data
Co-authored-by: Copilot <copilot@github.com>
2026-05-13 20:52:53 -06:00
jalvarado 2497488491 Merge pull request 'develop' (#39) from develop into master
Reviewed-on: #39
2026-03-10 15:43:56 -06:00
miguel f4bd054aa1 Refactor respuesta validation in submitRespuestas method for improved clarity 2026-03-09 19:13:26 -06:00
miguel aa2a0f1073 Implementar carga de usuarios desde archivo Excel y validación de lista restringida para eventos 2026-03-08 19:19:30 -06:00
miguel 4550af6e35 Merge branch 'develop' 2025-10-28 14:36:58 -06:00
miguel 2be866e7f9 Merge branch 'master' of https://repositorio.acatlan.unam.mx/CIDWA/formularios_api 2025-10-28 14:34:47 -06:00
miguel 6e82e7fdfd mensaje personalizado por tipo de evento 2025-10-28 14:29:50 -06:00
miguel e1ddf84166 mensaje personalizado por tipo de evento 2025-10-28 14:28:47 -06:00
miguel b244c66b1f mensaje personalizado por tipo de evento 2025-10-28 14:28:31 -06:00
evenegas 29ee4e1999 filtado de eventos 2025-10-22 11:36:49 -06:00
evenegas c259649a8d cambio en el correo 2025-10-21 17:44:51 -06:00
evenegas 4615306784 se agregó expiración 2025-10-06 13:50:11 -06:00
santiago 258a03e7b4 Modificaciones 2025-10-06 13:50:11 -06:00
evenegas ba1f39502e qr 2025-10-06 13:50:11 -06:00
evenegas ec76bb4835 merge 2025-10-06 13:50:11 -06:00
evenegas 53fe436e34 se agregó expiración 2025-10-06 13:50:11 -06:00
santiago e81abcb665 Modificaciones 2025-10-06 13:50:00 -06:00
evenegas 12080aa83e qr 2025-10-06 13:49:22 -06:00
evenegas 41d36f540a Se creo un evento por xlsx 2025-10-06 13:48:33 -06:00
evenegas e4ce66684f Se agregó una nueva plantilla 2025-10-06 12:20:24 -06:00
santiago c0b3087d76 Merge branch 'develop' of https://repositorio.acatlan.unam.mx/CIDWA/formularios_api into develop 2025-10-03 14:29:55 -06:00
santiago 3db1076ee6 Modificaciones 2025-10-03 14:29:14 -06:00
evenegas 8196ea8a31 se agregó expiración 2025-10-03 14:27:18 -06:00
evenegas e7b3540170 se agregó expiración 2025-10-03 14:08:17 -06:00
evenegas 0837bdd17b se agregó log de fecha fin 2025-10-03 12:57:56 -06:00
evenegas e4c2a16c8e se agregó cupo máximo 2025-10-03 12:01:18 -06:00
santiago 00e758f0ef Modificaciones 2025-10-02 16:03:52 -06:00
evenegas d070a4bc8f plantilla 2025-10-02 11:10:35 -06:00
evenegas 2ea0119b87 plantilla 2025-10-02 11:08:51 -06:00
evenegas 3cccee58f6 plantilla 2025-10-02 11:02:44 -06:00
evenegas 55a96d16f6 plantilla 2025-10-02 10:48:26 -06:00
evenegas 2a7a10ad9b plantilla 2025-10-02 10:31:42 -06:00
evenegas 663be0c7c6 Se agregó plantillas 2025-10-01 17:52:36 -06:00
santiago 613a44c355 Se modifico el qr y la carga masiva 2025-10-01 15:37:30 -06:00
santiago 0e8961f792 Modificaciones en la validacion del qr 2025-10-01 14:16:19 -06:00
santiago 6eb53a327a Modificciones sin subir 2025-10-01 12:46:12 -06:00
evenegas be72ee5fa5 qr 2025-10-01 12:43:37 -06:00
evenegas 7b8c19fbd1 qr 2025-10-01 12:23:14 -06:00
evenegas 0745ddc8b4 qr 2025-10-01 12:11:44 -06:00
evenegas f346da673b qr 2025-10-01 12:00:14 -06:00
evenegas 8690c82429 qr 2025-10-01 11:33:17 -06:00
evenegas 1e09c14767 qr 2025-10-01 11:26:49 -06:00
santiago 794d2bcd90 Modificaciones de caducidad de token 2025-10-01 10:30:27 -06:00
santiago 01b5c090ec modificaciones pero sin enviar 2025-10-01 10:17:19 -06:00
evenegas 919d94cb0e Se creo un evento por xlsx 2025-10-01 10:14:27 -06:00
evenegas bbdeab629d Se agregó carga masiva 2025-09-30 18:45:23 -06:00
santiago a876c92dcf Correccion en la validacion del participante evento 2025-09-29 12:14:25 -06:00
santiago 960163fa4a Coreecion de error 2025-09-25 20:57:56 +02:00
santiago 25872c544f Se agregaron las dependencias 2025-09-25 20:13:26 +02:00
santiago 6926e84df3 Correcion de error al tomar asistencia con codigo qr 2025-09-25 19:58:08 +02:00
miguel f1fba6a6cf fix: update email content for event registration confirmation 2025-09-10 13:29:55 -06:00
jorge miguel 92851b240a feat: add endpoint to register participant attendance without token 2025-09-09 18:03:59 -06:00
jorge miguel 6fc315450d feat: add token validation endpoint and logic for QR token verification 2025-09-09 17:43:23 -06:00
jalvarado 574249e09a Merge pull request 'feat: update asistencia registration to use JWT token and modify TypeORM synchronization setting' (#35) from events-division into develop
Reviewed-on: #35
2025-09-09 15:19:00 -06:00
jalvarado d0ae2f14cb Merge pull request 'events-division' (#34) from events-division into develop
Reviewed-on: #34
2025-08-19 10:28:24 -06:00
eithan ff3f799549 Merge pull request 'fecha asistencia' (#28) from develop into master
Reviewed-on: #28
2025-04-25 13:56:47 -06:00
32 changed files with 2514 additions and 243 deletions
+1101 -175
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -38,6 +38,7 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"exceljs": "^4.4.0",
"mysql2": "^3.14.0", "mysql2": "^3.14.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
+74 -5
View File
@@ -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 { 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 { AlumnoDto, ALUMNO_RESPONSES } from './alumnos.documentation';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname } from 'path';
@ApiTags('alumnos') @ApiTags('alumnos')
@Controller('alumnos') @Controller('alumnos')
export class AlumnosController { export class AlumnosController {
constructor(private readonly alumnosService: AlumnosService) {} 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') @Get(':cuenta')
@ApiOperation({ summary: 'Obtener datos de un alumno por su número de cuenta' }) @ApiOperation({ summary: 'Obtener datos de un alumno por su número de cuenta' })
@ApiParam({ name: 'cuenta', description: 'Número de cuenta del alumno' }) @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[200])
@ApiResponse(ALUMNO_RESPONSES[404]) @ApiResponse(ALUMNO_RESPONSES[404])
@ApiResponse(ALUMNO_RESPONSES[500]) @ApiResponse(ALUMNO_RESPONSES[500])
async getAlumnoByCuenta(@Param('cuenta') cuenta: string): Promise<UsuarioData | null> { async getAlumnoByCuenta(
const alumno = await this.alumnosService.findByCuenta(cuenta); @Param('cuenta') cuenta: string,
@Query('id_evento') idEventoRaw?: string,
): Promise<UsuarioData | null> {
const id_evento =
idEventoRaw !== undefined ? parseInt(idEventoRaw, 10) : undefined;
const alumno = await this.alumnosService.findByCuenta(cuenta, id_evento);
if (!alumno) { 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; return alumno;
} }
+5 -1
View File
@@ -3,9 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AlumnosController } from './alumnos.controller'; import { AlumnosController } from './alumnos.controller';
import { AlumnosService } from './alumnos.service'; import { AlumnosService } from './alumnos.service';
import { RegistroAlumno } from './entities/registro-alumno.entity'; import { RegistroAlumno } from './entities/registro-alumno.entity';
import { EventoUsuarioModule } from '../evento_usuario/evento_usuario.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([RegistroAlumno], 'alumnosConnection')], imports: [
TypeOrmModule.forFeature([RegistroAlumno], 'alumnosConnection'),
EventoUsuarioModule,
],
controllers: [AlumnosController], controllers: [AlumnosController],
providers: [AlumnosService], providers: [AlumnosService],
exports: [AlumnosService], exports: [AlumnosService],
+93 -10
View File
@@ -1,7 +1,14 @@
import { Injectable } from '@nestjs/common'; import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { RegistroAlumno } from './entities/registro-alumno.entity'; 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 = { export type UsuarioData = {
cuenta: string | null; cuenta: string | null;
@@ -10,26 +17,102 @@ export type UsuarioData = {
carrera: string | null; carrera: string | null;
genero: string | null; genero: string | null;
rfc?: string | null; // Solo para trabajadores rfc?: string | null; // Solo para trabajadores
} };
@Injectable() @Injectable()
export class AlumnosService { export class AlumnosService {
constructor( constructor(
@InjectRepository(RegistroAlumno, 'alumnosConnection') @InjectRepository(RegistroAlumno, 'alumnosConnection')
private registroAlumnoRepository: Repository<RegistroAlumno>, private registroAlumnoRepository: Repository<RegistroAlumno>,
private readonly eventoUsuarioService: EventoUsuarioService,
) {} ) {}
async findByCuenta(cuenta: string): Promise<UsuarioData | null> { 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<UsuarioData | null> {
const alumno = await this.registroAlumnoRepository.findOne({ const alumno = await this.registroAlumnoRepository.findOne({
where: { id_ncuenta: parseInt(cuenta, 10) }, where: { id_ncuenta: parseInt(cuenta, 10) },
}); });
return { if (!alumno) {
cuenta: alumno?.id_ncuenta.toString() || null, throw new NotFoundException(
nombre: alumno?.nombre || null, `No se encontró alumno con número de cuenta ${cuenta}`,
apellidos: alumno?.apellidos || null, );
carrera: alumno?.carrera || null,
genero: alumno?.genero || null,
} }
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,
};
} }
} }
+2
View File
@@ -29,6 +29,7 @@ import { ValidacionesModule } from './validaciones/validaciones.module';
import { AlumnosModule } from './alumnos/alumnos.module'; import { AlumnosModule } from './alumnos/alumnos.module';
import { TrabajadoresModule } from './trabajadores/trabajadores.module'; import { TrabajadoresModule } from './trabajadores/trabajadores.module';
import { TipoEventoModule } from './tipo_evento/tipo_evento.module'; import { TipoEventoModule } from './tipo_evento/tipo_evento.module';
import { EventoUsuarioModule } from './evento_usuario/evento_usuario.module';
@Module({ @Module({
imports: [ imports: [
@@ -69,6 +70,7 @@ import { TipoEventoModule } from './tipo_evento/tipo_evento.module';
ValidacionesModule, ValidacionesModule,
QrModule, QrModule,
TrabajadoresModule, TrabajadoresModule,
EventoUsuarioModule,
], ],
controllers: [AppController], controllers: [AppController],
+98 -1
View File
@@ -9,15 +9,23 @@ import {
UseInterceptors, UseInterceptors,
ParseIntPipe, ParseIntPipe,
UploadedFile, UploadedFile,
UnprocessableEntityException,
UploadedFiles,
BadRequestException,
HttpStatus,
HttpException,
} from '@nestjs/common'; } from '@nestjs/common';
import { CuestionarioService } from './cuestionario.service'; import { CuestionarioService } from './cuestionario.service';
import { CreateCuestionarioDto } from './dto/create-cuestionario.dto'; import { CreateCuestionarioDto } from './dto/create-cuestionario.dto';
import { UpdateCuestionarioDto } from './dto/update-cuestionario.dto'; import { UpdateCuestionarioDto } from './dto/update-cuestionario.dto';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
import { extname } from 'path'; import { extname } from 'path';
import { CuestionarioApiDocumentation } from './docs/cuestionario.documentation'; import { CuestionarioApiDocumentation } from './docs/cuestionario.documentation';
import { CreateEventoWithCuestionarioDto } from './dto/create-evento-cuestionario.dto'; import { CreateEventoWithCuestionarioDto } from './dto/create-evento-cuestionario.dto';
import * as fs from 'fs';
import { NotFoundError } from 'rxjs';
import { error } from 'console';
@Controller('cuestionario') @Controller('cuestionario')
@CuestionarioApiDocumentation.ApiController @CuestionarioApiDocumentation.ApiController
@@ -30,6 +38,92 @@ export class CuestionarioController {
return this.cuestionarioService.create(createCuestionarioDto); return this.cuestionarioService.create(createCuestionarioDto);
} }
@Post('banners/bulk')
@UseInterceptors(
FilesInterceptor('banners', 20, { // puedes subir hasta 20 imágenes
storage: diskStorage({
destination: './uploads/banners',
filename: (req, file, cb) => {
cb(null, file.originalname);
},
}),
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|webp/;
const ext = extname(file.originalname).toLowerCase();
if (allowedTypes.test(ext)) {
cb(null, true);
} else {
cb(new Error('Tipo de archivo no permitido'), false);
}
},
}),
)async subirBanners(@UploadedFiles() files: Express.Multer.File[]) {
if (!files || files.length === 0) {
throw new UnprocessableEntityException('No se subió ningún archivo');
}
// Retornamos lista de filenames para asociarlos después
return files.map((file) => ({
originalName: file.originalname,
filename: file.filename,
path: file.path,
}));
}
@Post('carga-masiva')
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: (req, file, cb) => {
const uploadPath = './uploads/excel';
// Crear la carpeta si no existe
fs.mkdirSync(uploadPath, { recursive: true });
cb(null, uploadPath);
},
filename: (req, file, cb) => {
// Guardar el archivo con timestamp + nombre original
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
const fileExt = extname(file.originalname); // obtener extensión
cb(null, `${uniqueSuffix}${fileExt}`);
},
}),
fileFilter: (req, file, cb) => {
// Solo permitir archivos .xlsx
if (file.mimetype === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') {
cb(null, true);
} else {
cb(new BadRequestException('Solo se permiten archivos .xlsx'), false);
}
},
}),
)
async cargaMasiva(@UploadedFile() file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('No se ha subido ningún archivo');
}
console.log('Archivo guardado en:', file.path);
// Leer el archivo con ExcelJS
// Aquí tu lógica para procesar las hojas del Excel
const cuestionarios = await this.cuestionarioService.cargarEventosDesdeExcel(file.path); // Ejemplo de arreglo de datos procesados
// ...
if (!cuestionarios.data) {
throw error;
}
return {
message: cuestionarios.message,
total: cuestionarios.data.length,
data: cuestionarios,
};
}
@Post('withEvento') @Post('withEvento')
@CuestionarioApiDocumentation.ApiCreateWithEvento @CuestionarioApiDocumentation.ApiCreateWithEvento
createCuestionarioEvento( createCuestionarioEvento(
@@ -40,6 +134,9 @@ export class CuestionarioController {
); );
} }
@Post(':id/banner') @Post(':id/banner')
@UseInterceptors( @UseInterceptors(
FileInterceptor('banner', { FileInterceptor('banner', {
+192 -4
View File
@@ -2,6 +2,7 @@ import {
Injectable, Injectable,
InternalServerErrorException, InternalServerErrorException,
NotFoundException, NotFoundException,
UnprocessableEntityException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, In, MoreThan } from 'typeorm'; import { Repository, DataSource, In, MoreThan } from 'typeorm';
@@ -13,7 +14,7 @@ import { UpdateCuestionarioDto } from './dto/update-cuestionario.dto';
import { Cuestionario } from './entities/cuestionario.entity'; import { Cuestionario } from './entities/cuestionario.entity';
import { Seccion } from '../seccion/entities/seccion.entity'; import { Seccion } from '../seccion/entities/seccion.entity';
import { CuestionarioSeccion } from '../cuestionario_seccion/entities/cuestionario_seccion.entity'; import { CuestionarioSeccion } from '../cuestionario_seccion/entities/cuestionario_seccion.entity';
import { Pregunta } from '../pregunta/entities/pregunta.entity'; import { Pregunta, TiposValidacion } from '../pregunta/entities/pregunta.entity';
import { SeccionPregunta } from '../seccion_pregunta/entities/seccion_pregunta.entity'; import { SeccionPregunta } from '../seccion_pregunta/entities/seccion_pregunta.entity';
import { Opcion } from '../opcion/entities/opcion.entity'; import { Opcion } from '../opcion/entities/opcion.entity';
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity'; import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
@@ -24,6 +25,8 @@ import {
import { Evento } from '../evento/entities/evento.entity'; import { Evento } from '../evento/entities/evento.entity';
import { EventoService } from '../evento/evento.service'; import { EventoService } from '../evento/evento.service';
import { CreateEventoWithCuestionarioDto } from './dto/create-evento-cuestionario.dto'; import { CreateEventoWithCuestionarioDto } from './dto/create-evento-cuestionario.dto';
import * as ExcelJS from 'exceljs';
import { PLANTILLAS } from './plantillas';
@Injectable() @Injectable()
export class CuestionarioService { export class CuestionarioService {
@@ -50,6 +53,187 @@ export class CuestionarioService {
private eventoService: EventoService, private eventoService: EventoService,
) {} ) {}
private mapTipoPregunta(tipo: string): TipoPreguntaEnum {
if (!tipo) {
console.log(tipo);
throw new Error('El campo "tipo de pregunta" está vacío en el Excel');
}
switch (tipo.toLowerCase()) {
case 'cerrada': return TipoPreguntaEnum.Cerrada;
case 'multiple': return TipoPreguntaEnum.Multiple;
case 'abierta (parrafo)': return TipoPreguntaEnum.AbiertaParrafo;
case 'abierta (respuesta corta)': return TipoPreguntaEnum.AbiertaRespuestaCorta;
default: throw new Error(`Tipo de pregunta inválido: ${tipo}`);
}
}
// Mapear validación desde string a enum
private mapValidacion(validacion?: string): TiposValidacion | undefined {
if (!validacion) return undefined;
switch (validacion.trim().toLowerCase()) {
case 'correo': return TiposValidacion.CORREO;
case 'correo_institucional': return TiposValidacion.CORREO_INSTITUCIONAL;
case 'telefono': return TiposValidacion.TELEFONO;
case 'nombre': return TiposValidacion.NOMBRE;
case 'apellidos': return TiposValidacion.APELLIDOS;
case 'entero': return TiposValidacion.ENTERO;
case 'decimal': return TiposValidacion.DECIMAL;
case 'comunidad_alumno': return TiposValidacion.COMUNIDAD_ALUMNO;
case 'cuenta_alumno': return TiposValidacion.CUENTA_ALUMNO;
case 'comunidad_trabajador': return TiposValidacion.COMUNIDAD_TRABAJADOR;
case 'cuenta_trabajador': return TiposValidacion.CUENTA_TRABAJADOR;
case 'genero': return TiposValidacion.GENERO;
case 'carrera': return TiposValidacion.CARRERA;
case 'institucion': return TiposValidacion.INSTITUCION;
case 'rfc': return TiposValidacion.RFC;
default: return undefined;
}
}
async cargarEventosDesdeExcel(filePath: string) {
const PLANTILLA_MAP: Record<number, string> = {
1: 'registro-comunidad-alumnos',
2: 'registro-comunidad-trabajadores',
3: 'registro-general',
4: 'registro-general-externos',
};
let data: any[] = [];
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(filePath);
const eventosSheet = workbook.getWorksheet('Eventos');
const cuestionariosSheet = workbook.getWorksheet('Cuestionarios');
const seccionesSheet = workbook.getWorksheet('Secciones');
const preguntasSheet = workbook.getWorksheet('Preguntas');
const opcionesSheet = workbook.getWorksheet('Opciones');
if (!eventosSheet || !cuestionariosSheet || !seccionesSheet || !preguntasSheet || !opcionesSheet) {
throw new UnprocessableEntityException('El archivo Excel debe contener las hojas: Eventos, Cuestionarios, Secciones, Preguntas y Opciones.');
}
for (let i = 2; i <= eventosSheet.rowCount; i++) {
const row = eventosSheet.getRow(i);
// Construir objeto evento
const eventoDto = {
tipo_evento: row.getCell(2).value as string,
nombre_evento: row.getCell(3).value as string,
descripcion_evento: row.getCell(4).value as string,
fecha_inicio: new Date(row.getCell(5).value as string),
fecha_fin: new Date(row.getCell(6).value as string),
banner: row.getCell(7).value as string, // nombre del archivo subido previamente
};
// Filtrar cuestionarios de este evento
const cuestionarios = cuestionariosSheet.getRows(2, cuestionariosSheet.rowCount - 1)
?.filter(qRow => qRow.getCell(2).value === row.getCell(1).value) || [];
for (const qRow of cuestionarios) {
const plantilla = qRow.getCell(10).value ? Number(qRow.getCell(10).value) : null;
console.log("Valor de la plantilla: ", plantilla);
if (plantilla !== null && plantilla !== undefined) {
console.log("Tipo de la plantilla: ", PLANTILLA_MAP[plantilla]);
}
if (plantilla && PLANTILLA_MAP[plantilla]) {
console.log("fecha fin desde ecxel: ",qRow.getCell(5).value as string);
const fecha_inicio = new Date(qRow.getCell(5).value as string);
console.log("Fecha de inicio: ", fecha_inicio);
const fecha_fin = new Date(qRow.getCell(6).value as string);
console.log("Fecha de fin: ", fecha_fin);
const id_tipo_evento= Number(qRow.getCell(8).value);
const plantillaId = PLANTILLA_MAP[plantilla];
const base = PLANTILLAS.find(p => p.id === plantillaId);
if (!base) throw new Error(`Plantilla ${plantillaId} no encontrada`);
console.log("ID de la plantilla: ", plantillaId);
console.log("Base de la plantilla: ", base);
const nombre_form= qRow.getCell(3).value as string;
const descripcion= qRow.getCell(4).value as string;
const cupo_maximo= Number(qRow.getCell(9).value) || undefined;
const createDto: CreateEventoWithCuestionarioDto = {
evento: eventoDto,
cuestionario: {
...base.datos,
fecha_inicio,
fecha_fin,
id_tipo_evento,
nombre_form,
descripcion,
cupo_maximo, // Aquí puedes agregar más campos si es necesario
},
};
data.push(await this.createCuestionarioEvento(createDto));
continue; // Saltar al siguiente cuestionario
}
// Filtrar secciones de este cuestionario
const secciones = seccionesSheet.getRows(2, seccionesSheet.rowCount - 1)
?.filter(sRow => sRow.getCell(2).value === qRow.getCell(1).value)
.map(sRow => {
// Filtrar preguntas de esta sección
const preguntas = preguntasSheet.getRows(2, preguntasSheet.rowCount - 1)
?.filter(pRow => pRow.getCell(2).value === sRow.getCell(1).value)
.map(pRow => {
const opciones = opcionesSheet.getRows(2, opcionesSheet.rowCount - 1)
?.filter(oRow => oRow.getCell(2).value === pRow.getCell(1).value)
.map(oRow => ({ valor: oRow.getCell(3).value as string })) || [];
console.log("Este es el titulo: ", pRow.getCell(3).value as string);
console.log("Este es el tipo de la pregunta: ",pRow.getCell(4).value as string);
return {
titulo: pRow.getCell(3).value as string,
tipo: this.mapTipoPregunta(pRow.getCell(4).value as string),
obligatoria: !!pRow.getCell(5).value,
validacion: this.mapValidacion(pRow.getCell(6).value as string),
opciones,
};
}) || [];
return {
titulo: sRow.getCell(3).value as string,
descripcion: sRow.getCell(4).value as string,
preguntas,
};
}) || [];
// Construir DTO completo
const createDto: CreateEventoWithCuestionarioDto = {
evento: eventoDto,
cuestionario: {
nombre_form: qRow.getCell(3).value as string,
descripcion: qRow.getCell(4).value as string,
fecha_inicio: new Date(qRow.getCell(5).value as string),
fecha_fin: new Date(qRow.getCell(6).value as string),
id_tipo_cuestionario: Number(qRow.getCell(7).value),
id_tipo_evento: Number(qRow.getCell(8).value), // obligatorio
cupo_maximo: Number(qRow.getCell(9).value) || undefined,
secciones,
},
};
// Llamada a tu servicio para guardar en DB
data.push(await this.createCuestionarioEvento(createDto));
}
}
return { message: 'Carga masiva completada', data };
}
async create(createCuestionarioDto: CreateCuestionarioDto) { async create(createCuestionarioDto: CreateCuestionarioDto) {
// Verificar que el evento exista // Verificar que el evento exista
await this.eventoService.getEventoOrFail(createCuestionarioDto.id_evento); await this.eventoService.getEventoOrFail(createCuestionarioDto.id_evento);
@@ -73,6 +257,7 @@ export class CuestionarioService {
cuestionario.editable = true; cuestionario.editable = true;
cuestionario.id_evento = createCuestionarioDto.id_evento; cuestionario.id_evento = createCuestionarioDto.id_evento;
cuestionario.cupo_maximo = createCuestionarioDto.cupo_maximo; cuestionario.cupo_maximo = createCuestionarioDto.cupo_maximo;
cuestionario.mensaje_correo = createCuestionarioDto.mensaje_correo;
const savedCuestionario = await queryRunner.manager.save(cuestionario); const savedCuestionario = await queryRunner.manager.save(cuestionario);
@@ -209,17 +394,19 @@ export class CuestionarioService {
await queryRunner.startTransaction(); await queryRunner.startTransaction();
try { try {
/*
// Buscar si ya existe un evento con el nombre proporcionado // Buscar si ya existe un evento con el nombre proporcionado
let eventoExistente = await this.eventoRepository.findOne({ let eventoExistente = await this.eventoRepository.findOne({
where: { nombre_evento: evento.nombre_evento }, where: { nombre_evento: evento.nombre_evento },
}); });
**/
// Si el evento no existe, crearlo // Si el evento no existe, crearlo
if (!eventoExistente) { //if (!eventoExistente) {
eventoExistente = queryRunner.manager.create(Evento, evento); let eventoExistente = queryRunner.manager.create(Evento, evento);
await queryRunner.manager.save(eventoExistente); await queryRunner.manager.save(eventoExistente);
console.log(`Creando nuevo evento: ${evento.nombre_evento}`); console.log(`Creando nuevo evento: ${evento.nombre_evento}`);
} //}
// Crear el cuestionario asociado al evento // Crear el cuestionario asociado al evento
const cuestionarioEntity = queryRunner.manager.create(Cuestionario, { const cuestionarioEntity = queryRunner.manager.create(Cuestionario, {
@@ -233,6 +420,7 @@ export class CuestionarioService {
editable: true, editable: true,
id_evento: eventoExistente.id_evento, // Asociar el evento creado id_evento: eventoExistente.id_evento, // Asociar el evento creado
cupo_maximo: cuestionario.cupo_maximo, cupo_maximo: cuestionario.cupo_maximo,
mensaje_correo: cuestionario.mensaje_correo,
}); });
const savedCuestionario = const savedCuestionario =
await queryRunner.manager.save(cuestionarioEntity); await queryRunner.manager.save(cuestionarioEntity);
@@ -89,6 +89,15 @@ export class CreateCuestionarioDto {
@IsString() @IsString()
evento?: string; 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({ @ApiProperty({
description: 'Secciones del cuestionario', description: 'Secciones del cuestionario',
type: [CreateSeccionDto], type: [CreateSeccionDto],
@@ -51,6 +51,9 @@ export class Cuestionario {
@Column({ type: 'int' }) @Column({ type: 'int' })
id_evento: number; id_evento: number;
@Column({ type: 'text', nullable: true })
mensaje_correo?: string;
// Relaciones // Relaciones
@ManyToOne(() => Evento, (evento) => evento.cuestionarios, { nullable: true }) @ManyToOne(() => Evento, (evento) => evento.cuestionarios, { nullable: true })
@JoinColumn({ name: 'id_evento' }) @JoinColumn({ name: 'id_evento' })
+165
View File
@@ -0,0 +1,165 @@
import { TiposValidacion } from 'src/pregunta/entities/pregunta.entity';
import {
TipoPreguntaEnum,
} from '../tipo_pregunta/entities/tipo_pregunta.entity';
export const PLANTILLAS = [
{
imagen: '/plantilla_trabajadores.png',
nombre: 'Registro Comunidad Trabajador',
id: 'registro-comunidad-trabajadores',
datos: {
nombre_form: 'Registro',
descripcion:
'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
id_tipo_cuestionario: 2,
secciones: [
{
titulo: 'Información Personal',
descripcion:
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
preguntas: [
{
titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
tipo: TipoPreguntaEnum.Cerrada,
opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true,
validacion: TiposValidacion.COMUNIDAD_TRABAJADOR,
},
{ titulo: 'RFC', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: false, validacion: TiposValidacion.RFC },
{ titulo: 'Correo electrónico', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CORREO },
{ titulo: 'Nombre(s)', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.NOMBRE },
{ titulo: 'Apellidos', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.APELLIDOS },
{
titulo: 'Género',
tipo: TipoPreguntaEnum.Cerrada,
obligatoria: true,
validacion: TiposValidacion.GENERO,
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
{ titulo: 'Carrera', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CARRERA },
],
},
],
},
},
{
imagen: '/plantilla_alumnos.png',
nombre: 'Registro Comunidad Alumnos',
id: 'registro-comunidad-alumnos',
datos: {
nombre_form: 'Registro',
descripcion:
'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
id_tipo_cuestionario: 1,
secciones: [
{
titulo: 'Información Personal',
descripcion:
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
preguntas: [
{
titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
tipo: TipoPreguntaEnum.Cerrada,
opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true,
validacion: TiposValidacion.COMUNIDAD_ALUMNO,
},
{ titulo: 'Numero de cuenta', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: false, validacion: TiposValidacion.CUENTA_ALUMNO },
{ titulo: 'Correo electrónico', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CORREO },
{ titulo: 'Nombre(s)', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.NOMBRE },
{ titulo: 'Apellidos', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.APELLIDOS },
{
titulo: 'Género',
tipo: TipoPreguntaEnum.Cerrada,
obligatoria: true,
validacion: TiposValidacion.GENERO,
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
{ valor: 'Prefiero no decirlo' },
],
},
{ titulo: 'Institución de procedencia', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.INSTITUCION },
{ titulo: 'Carrera', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CARRERA },
],
},
],
},
},
{
imagen: '/plantilla_general.png',
nombre: 'Registro General',
id: 'registro-general',
datos: {
nombre_form: 'Registro Para Asistencia General',
descripcion:
'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
id_tipo_cuestionario: 1,
secciones: [
{
titulo: 'Información Personal',
descripcion:
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
preguntas: [
{ titulo: 'Número de cuenta / trabajador', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: false },
{ titulo: 'Correo electrónico', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CORREO },
{ titulo: 'Nombre(s)', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.NOMBRE },
{ titulo: 'Apellidos', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.APELLIDOS },
{
titulo: 'Tipo de Asistente',
tipo: TipoPreguntaEnum.Cerrada,
obligatoria: true,
opciones: [
{ valor: 'Alumno' },
{ valor: 'Profesor' },
{ valor: 'Trabajador' },
],
},
],
},
],
},
},
{
imagen: '/plantilla_general.png',
nombre: 'Registro General Externos',
id: 'registro-general-externos',
datos: {
nombre_form: 'Registro Para Asistencia General',
descripcion:
'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.',
id_tipo_cuestionario: 1,
secciones: [
{
titulo: 'Información Personal',
descripcion:
'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.',
preguntas: [
{ titulo: 'Número de cuenta / trabajador', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: false },
{ titulo: 'Correo electrónico', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CORREO },
{ titulo: 'Nombre(s)', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.NOMBRE },
{ titulo: 'Apellidos', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.APELLIDOS },
{
titulo: 'Tipo de Asistente',
tipo: TipoPreguntaEnum.Cerrada,
obligatoria: true,
opciones: [
{ valor: 'Alumno' },
{ valor: 'Profesor' },
{ valor: 'Trabajador' },
{ valor: 'Externo'},
],
},
],
},
],
},
},
];
@@ -14,6 +14,7 @@ import { RespuestaParticipanteCerrada } from '../respuesta_participante_cerrada/
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity'; import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
import { Opcion } from '../opcion/entities/opcion.entity'; import { Opcion } from '../opcion/entities/opcion.entity';
import { ValidacionesModule } from '../validaciones/validaciones.module'; import { ValidacionesModule } from '../validaciones/validaciones.module';
import { EventoUsuarioModule } from '../evento_usuario/evento_usuario.module';
@Module({ @Module({
imports: [ imports: [
@@ -31,6 +32,7 @@ import { ValidacionesModule } from '../validaciones/validaciones.module';
ParticipanteModule, ParticipanteModule,
ValidacionesModule, ValidacionesModule,
QrModule, QrModule,
EventoUsuarioModule,
], ],
controllers: [CuestionarioRespondidoController], controllers: [CuestionarioRespondidoController],
providers: [CuestionarioRespondidoService], providers: [CuestionarioRespondidoService],
@@ -4,7 +4,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; 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 { CreateCuestionarioRespondidoDto } from './dto/create-cuestionario_respondido.dto';
import { UpdateCuestionarioRespondidoDto } from './dto/update-cuestionario_respondido.dto'; import { UpdateCuestionarioRespondidoDto } from './dto/update-cuestionario_respondido.dto';
import { SubmitRespuestasDto } from './dto/submit-respuestas.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 { CuestionarioService } from 'src/cuestionario/cuestionario.service';
import { generarHtmlCorreoAsistencia } from 'src/emails/registro-evento'; import { generarHtmlCorreoAsistencia } from 'src/emails/registro-evento';
import { TipoPreguntaEnum } from 'src/tipo_pregunta/entities/tipo_pregunta.entity'; 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() @Injectable()
export class CuestionarioRespondidoService { export class CuestionarioRespondidoService {
constructor( constructor(
@@ -41,6 +44,7 @@ export class CuestionarioRespondidoService {
private validadorRespuestasService: ValidadorRespuestasService, private validadorRespuestasService: ValidadorRespuestasService,
private cuestionarioService: CuestionarioService, private cuestionarioService: CuestionarioService,
private qrTokenService: QrTokenService, private qrTokenService: QrTokenService,
private eventoUsuarioService: EventoUsuarioService,
) {} ) {}
create(createCuestionarioRespondidoDto: CreateCuestionarioRespondidoDto) { create(createCuestionarioRespondidoDto: CreateCuestionarioRespondidoDto) {
@@ -79,6 +83,72 @@ 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 &&
respuesta.valor !== null &&
respuesta.valor !== undefined
) {
const valorStr = String(respuesta.valor).trim();
if (valorStr) {
identificador = valorStr.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 // 2. Buscar o crear participante
let participante = await this.participanteRepository.findOne({ let participante = await this.participanteRepository.findOne({
where: { correo: submitDto.correo }, where: { correo: submitDto.correo },
@@ -116,6 +186,7 @@ export class CuestionarioRespondidoService {
participante.id_participante, participante.id_participante,
cuestionario.evento.id_evento, cuestionario.evento.id_evento,
cuestionario.id_cuestionario, cuestionario.id_cuestionario,
cuestionario.fecha_fin,
); );
// Generar código QR con el token // Generar código QR con el token
@@ -135,6 +206,7 @@ export class CuestionarioRespondidoService {
nombreForm: cuestionario.nombre_form, nombreForm: cuestionario.nombre_form,
nombreEvento: cuestionario.evento?.nombre_evento, nombreEvento: cuestionario.evento?.nombre_evento,
correo: participante.correo, correo: participante.correo,
tipoEvento: cuestionario.evento.tipo_evento,
fechaRegistro: new Date().toLocaleString(), fechaRegistro: new Date().toLocaleString(),
fechaInicioEvento: cuestionario.evento?.fecha_inicio fechaInicioEvento: cuestionario.evento?.fecha_inicio
? new Date( ? new Date(
@@ -144,6 +216,7 @@ export class CuestionarioRespondidoService {
fechaFinEvento: cuestionario.evento?.fecha_fin fechaFinEvento: cuestionario.evento?.fecha_fin
? new Date(cuestionario.evento.fecha_fin).toLocaleString() ? new Date(cuestionario.evento.fecha_fin).toLocaleString()
: undefined, : undefined,
mensajePersonalizado: cuestionario.mensaje_correo,
}), }),
adjuntos: [ adjuntos: [
{ {
@@ -340,6 +413,7 @@ export class CuestionarioRespondidoService {
participante.id_participante, participante.id_participante,
cuestionario.evento.id_evento, cuestionario.evento.id_evento,
cuestionario.id_cuestionario, cuestionario.id_cuestionario,
cuestionario.fecha_fin,
); );
qrBuffer = await QRCode.toBuffer(qrToken); qrBuffer = await QRCode.toBuffer(qrToken);
@@ -363,6 +437,7 @@ export class CuestionarioRespondidoService {
nombreForm: cuestionario.nombre_form, nombreForm: cuestionario.nombre_form,
nombreEvento: cuestionario.evento?.nombre_evento, nombreEvento: cuestionario.evento?.nombre_evento,
correo: participante.correo, correo: participante.correo,
tipoEvento: cuestionario.evento.tipo_evento,
fechaRegistro: new Date().toLocaleString(), fechaRegistro: new Date().toLocaleString(),
fechaInicioEvento: cuestionario.evento?.fecha_inicio fechaInicioEvento: cuestionario.evento?.fecha_inicio
? new Date(cuestionario.evento.fecha_inicio).toLocaleString() ? new Date(cuestionario.evento.fecha_inicio).toLocaleString()
@@ -370,6 +445,7 @@ export class CuestionarioRespondidoService {
fechaFinEvento: cuestionario.evento?.fecha_fin fechaFinEvento: cuestionario.evento?.fecha_fin
? new Date(cuestionario.evento.fecha_fin).toLocaleString() ? new Date(cuestionario.evento.fecha_fin).toLocaleString()
: undefined, : undefined,
mensajePersonalizado: cuestionario.mensaje_correo,
}), }),
adjuntos: [ adjuntos: [
{ {
+3 -3
View File
@@ -21,6 +21,7 @@ import { TipoCuestionario } from 'src/tipo_cuestionario/entities/tipo_cuestionar
import { TipoPregunta } from 'src/tipo_pregunta/entities/tipo_pregunta.entity'; import { TipoPregunta } from 'src/tipo_pregunta/entities/tipo_pregunta.entity';
import { TipoUser } from 'src/tipo_user/entities/tipo_user.entity'; import { TipoUser } from 'src/tipo_user/entities/tipo_user.entity';
import { TipoEvento } from 'src/tipo_evento/entities/tipo_evento.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 ( export const createMainDbConfig = async (
configService: ConfigService, configService: ConfigService,
@@ -51,13 +52,13 @@ export const createMainDbConfig = async (
TipoEvento, TipoEvento,
TipoPregunta, TipoPregunta,
TipoUser, TipoUser,
EventoUsuario,
], ],
retryAttempts: 5, retryAttempts: 5,
retryDelay: 3000, retryDelay: 3000,
connectTimeout: 30000, connectTimeout: 30000,
synchronize: configService.get<string>('SYNCHRONIZE') === 'true', synchronize: configService.get<string>('SYNCHRONIZE') === 'true',
dropSchema: configService.get<string>('DROP_SCHEMA') === 'true',
}); });
export const createAlumnosDbConfig = async ( export const createAlumnosDbConfig = async (
@@ -75,6 +76,5 @@ export const createAlumnosDbConfig = async (
retryDelay: 3000, retryDelay: 3000,
connectTimeout: 30000, connectTimeout: 30000,
synchronize: false, synchronize: true,
dropSchema: false,
}); });
+23 -4
View File
@@ -1,3 +1,17 @@
const messagePorTipoEvento: Record<string, string> = {
'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({ export function generarHtmlCorreoAsistencia({
nombreForm, nombreForm,
nombreEvento, nombreEvento,
@@ -5,16 +19,21 @@ export function generarHtmlCorreoAsistencia({
fechaRegistro, fechaRegistro,
fechaInicioEvento, fechaInicioEvento,
fechaFinEvento, fechaFinEvento,
tipoEvento,
mensajePersonalizado,
}: { }: {
nombreForm: string; nombreForm: string;
nombreEvento: string; nombreEvento: string;
correo: string; correo: string;
tipoEvento: string;
fechaRegistro: string; fechaRegistro: string;
fechaInicioEvento?: string; fechaInicioEvento?: string;
fechaFinEvento?: string; fechaFinEvento?: string;
mensajePersonalizado?: string;
}) { }) {
const horarioEvento = fechaInicioEvento && fechaFinEvento const horarioEvento =
? ` fechaInicioEvento && fechaFinEvento
? `
<div style="margin: 20px 0; padding: 12px; background-color: #e8f0fe; border-left: 4px solid #003d79;"> <div style="margin: 20px 0; padding: 12px; background-color: #e8f0fe; border-left: 4px solid #003d79;">
<p style="margin: 0; font-size: 15px; color: #003d79;"> <p style="margin: 0; font-size: 15px; color: #003d79;">
<strong>🕒 Horario del evento:</strong><br/> <strong>🕒 Horario del evento:</strong><br/>
@@ -22,7 +41,7 @@ export function generarHtmlCorreoAsistencia({
</p> </p>
</div> </div>
` `
: ''; : '';
return ` return `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; border: 1px solid #ccc; border-radius: 8px; background-color: #f9f9f9;"> <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; border: 1px solid #ccc; border-radius: 8px; background-color: #f9f9f9;">
@@ -33,7 +52,7 @@ export function generarHtmlCorreoAsistencia({
</p> </p>
<p style="font-size: 16px; color: #333;"> <p style="font-size: 16px; color: #333;">
Favor de presentar este código QR (celular o impreso) en la mesa de registro. el día del evento, para validar su asistencia y tener derecho a la constancia. ${mensajePersonalizado ?? getMensajeDefaultPorTipoEvento(tipoEvento)}
</p> </p>
<div style="text-align: center; margin: 20px 0;"> <div style="text-align: center; margin: 20px 0;">
+27
View File
@@ -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( static ApiPostBanner = applyDecorators(
ApiOperation({ summary: 'Subir banner para un evento' }), ApiOperation({ summary: 'Subir banner para un evento' }),
ApiParam({ ApiParam({
+4
View File
@@ -23,4 +23,8 @@ export class CreateEventoDto {
@IsDate({ message: 'La fecha de fin debe ser una fecha válida.' }) @IsDate({ message: 'La fecha de fin debe ser una fecha válida.' })
@IsNotEmpty({ message: 'La fecha de fin es obligatoria.' }) @IsNotEmpty({ message: 'La fecha de fin es obligatoria.' })
fecha_fin: Date; fecha_fin: Date;
@IsOptional()
@IsString()
banner?: string;
} }
+41 -1
View File
@@ -19,11 +19,15 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer'; import { diskStorage } from 'multer';
import { extname } from 'path'; import { extname } from 'path';
import { EventoApiDocumentation } from './docs/evento.documentation'; import { EventoApiDocumentation } from './docs/evento.documentation';
import { EventoUsuarioService } from '../evento_usuario/evento_usuario.service';
@Controller('evento') @Controller('evento')
@EventoApiDocumentation.ApiController @EventoApiDocumentation.ApiController
export class EventoController { export class EventoController {
constructor(private eventoService: EventoService) {} constructor(
private eventoService: EventoService,
private eventoUsuarioService: EventoUsuarioService,
) {}
@Post() @Post()
@EventoApiDocumentation.ApiCreate @EventoApiDocumentation.ApiCreate
@@ -64,6 +68,42 @@ export class EventoController {
return this.eventoService.getCuestionarioEvento(id_evento, id_cuestionario); 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') @Post(':id/banner')
@UseInterceptors( @UseInterceptors(
FileInterceptor('banner', { FileInterceptor('banner', {
+6 -2
View File
@@ -3,11 +3,15 @@ import { EventoService } from './evento.service';
import { EventoController } from './evento.controller'; import { EventoController } from './evento.controller';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Evento } from './entities/evento.entity'; import { Evento } from './entities/evento.entity';
import { EventoUsuarioModule } from '../evento_usuario/evento_usuario.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Evento])], imports: [
TypeOrmModule.forFeature([Evento]),
EventoUsuarioModule,
],
controllers: [EventoController], controllers: [EventoController],
providers: [EventoService], providers: [EventoService],
exports: [EventoService] exports: [EventoService],
}) })
export class EventoModule {} export class EventoModule {}
+4 -1
View File
@@ -127,7 +127,10 @@ export class EventoService {
relations: ['cuestionarios', 'cuestionarios.cuestionariosRespondidos'], relations: ['cuestionarios', 'cuestionarios.cuestionariosRespondidos'],
}); });
const eventosConCupos = eventos.map((evento) => { const eventosFiltrados = eventos.filter(
(evento) => !(evento.id_evento >= 10 && evento.id_evento <= 148),
);
const eventosConCupos = eventosFiltrados.map((evento) => {
return { return {
...evento, ...evento,
cuestionarios: evento.cuestionarios.map((cuest) => { cuestionarios: evento.cuestionarios.map((cuest) => {
@@ -0,0 +1,37 @@
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;
@Column({ type: 'int', nullable: true })
contador: number | null;
}
@@ -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 {}
@@ -0,0 +1,222 @@
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<EventoUsuario>,
@InjectRepository(RegistroAlumno, 'alumnosConnection')
private readonly alumnoRepo: Repository<RegistroAlumno>,
@InjectRepository(RegistroTrabajador, 'alumnosConnection')
private readonly trabajadorRepo: Repository<RegistroTrabajador>,
@InjectRepository(Evento)
private readonly eventoRepo: Repository<Evento>,
) {}
async eventoTieneListaRestringida(id_evento: number): Promise<boolean> {
const count = await this.eventoUsuarioRepo.count({ where: { id_evento } });
return count > 0;
}
async identificadorEnLista(
id_evento: number,
identificador: string,
): Promise<boolean> {
const row = await this.eventoUsuarioRepo.findOne({
where: {
id_evento,
identificador: identificador.trim().toUpperCase(),
},
});
return !!row;
}
async bulkInsert(
id_evento: number,
alumnos: { identificador: string; contador: number | null }[],
trabajadores: { identificador: string; contador: number | null }[],
): Promise<{ insertados: number; omitidos: number; errores: string[] }> {
let insertados = 0;
let omitidos = 0;
const errores: string[] = [];
for (const alumno of alumnos) {
try {
const existing = await this.eventoUsuarioRepo.findOne({
where: {
id_evento,
tipo_usuario: TipoUsuarioEnum.ALUMNO,
identificador: alumno.identificador.toUpperCase(),
},
});
if (existing) {
if (existing.contador !== alumno.contador) {
await this.eventoUsuarioRepo.update(existing.id_evento_usuario, {
contador: alumno.contador,
});
insertados++;
} else {
omitidos++;
}
} else {
await this.eventoUsuarioRepo.insert({
id_evento,
tipo_usuario: TipoUsuarioEnum.ALUMNO,
identificador: alumno.identificador.toUpperCase(),
contador: alumno.contador,
});
insertados++;
}
} catch {
omitidos++;
errores.push(`Error al insertar cuenta ${alumno.identificador}`);
}
}
for (const trabajador of trabajadores) {
try {
const existing = await this.eventoUsuarioRepo.findOne({
where: {
id_evento,
tipo_usuario: TipoUsuarioEnum.TRABAJADOR,
identificador: trabajador.identificador.toUpperCase(),
},
});
if (existing) {
if (existing.contador !== trabajador.contador) {
await this.eventoUsuarioRepo.update(existing.id_evento_usuario, {
contador: trabajador.contador,
});
insertados++;
} else {
omitidos++;
}
} else {
await this.eventoUsuarioRepo.insert({
id_evento,
tipo_usuario: TipoUsuarioEnum.TRABAJADOR,
identificador: trabajador.identificador.toUpperCase(),
contador: trabajador.contador,
});
insertados++;
}
} catch {
omitidos++;
errores.push(`Error al insertar RFC ${trabajador.identificador}`);
}
}
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: { identificador: string; contador: number | null }[] =
[];
const trabajadoresValidos: {
identificador: string;
contador: number | null;
}[] = [];
let omitidos = 0;
const errores: string[] = [];
for (const row of data) {
const cuentaRaw = row['cuenta'];
const rfcRaw = row['rfc'];
const contadorRaw = row['contador'];
const contador =
contadorRaw !== undefined &&
contadorRaw !== null &&
contadorRaw !== '' &&
!isNaN(Number(contadorRaw))
? Number(contadorRaw)
: null;
if (cuentaRaw !== undefined && cuentaRaw !== null && cuentaRaw !== '') {
const cuenta = String(cuentaRaw).trim();
const alumno = await this.alumnoRepo.findOne({
where: { id_ncuenta: parseInt(cuenta, 10) },
});
if (!alumno) {
omitidos++;
errores.push(`Cuenta no encontrada en el sistema: ${cuenta}`);
continue;
}
alumnosValidos.push({
identificador: alumno.id_ncuenta.toString(),
contador,
});
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({ identificador: trabajador.rfc, contador });
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),
};
}
}
@@ -233,6 +233,56 @@ export class ParticipanteEventoController {
); );
} }
@ApiOperation({
summary: 'Registrar asistencia de un participante a un evento',
})
@ApiParam({
name: 'idParticipante',
description: 'ID del participante',
type: 'number',
})
@ApiParam({ name: 'idEvento', description: 'ID del evento', type: 'number' })
@ApiResponse({
status: 200,
description: 'Asistencia registrada correctamente',
})
@ApiResponse({ status: 404, description: 'Registro no encontrado' })
@Post('asistencia/:idParticipante/:idEvento')
registrarAsistenciaSinToken(
@Param('idParticipante', ParseIntPipe) idParticipante: number,
@Param('idEvento', ParseIntPipe) idEvento: number,
) {
return this.participanteEventoService.registrarAsistenciaSinToken(
idParticipante,
idEvento,
);
}
@ApiOperation({
summary: 'Retornala info del token QR',
})
@ApiBody({
description: 'Token JWT generado por el código QR',
schema: {
type: 'object',
properties: {
token: { type: 'string' },
},
example: {
token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
},
},
})
@ApiResponse({
status: 200,
description: 'Token válido, información decodificada retornada',
})
@ApiResponse({ status: 400, description: 'Token inválido o expirado' })
@Post('decode-token')
decodeToken(@Body() body: { token: string }) {
return this.participanteEventoService.validToken(body.token);
}
@ApiOperation({ @ApiOperation({
summary: 'Registrar asistencia usando token QR', summary: 'Registrar asistencia usando token QR',
}) })
@@ -7,10 +7,13 @@ import { Evento } from 'src/evento/entities/evento.entity';
import { Participante } from 'src/participante/entities/participante.entity'; import { Participante } from 'src/participante/entities/participante.entity';
import { CuestionarioModule } from 'src/cuestionario/cuestionario.module'; import { CuestionarioModule } from 'src/cuestionario/cuestionario.module';
import { QrModule } from 'src/qr/qr.module'; import { QrModule } from 'src/qr/qr.module';
import { EventoUsuario } from 'src/evento_usuario/entities/evento_usuario.entity';
import { RespuestaParticipanteAbierta } from 'src/respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([ParticipanteEvento, Evento, Participante]), TypeOrmModule.forFeature([ParticipanteEvento, Evento, Participante, Cuestionario, EventoUsuario, RespuestaParticipanteAbierta]),
CuestionarioModule, CuestionarioModule,
QrModule, QrModule,
], ],
@@ -8,6 +8,10 @@ import { Evento } from 'src/evento/entities/evento.entity';
import { Participante } from 'src/participante/entities/participante.entity'; import { Participante } from 'src/participante/entities/participante.entity';
import { CuestionarioService } from 'src/cuestionario/cuestionario.service'; import { CuestionarioService } from 'src/cuestionario/cuestionario.service';
import { QrTokenService } from 'src/qr/qr-token.service'; import { QrTokenService } from 'src/qr/qr-token.service';
import { EventoUsuario } from 'src/evento_usuario/entities/evento_usuario.entity';
import { RespuestaParticipanteAbierta } from 'src/respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
import { TiposValidacion } from 'src/pregunta/entities/pregunta.entity';
import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity';
@Injectable() @Injectable()
export class ParticipanteEventoService { export class ParticipanteEventoService {
@@ -17,6 +21,12 @@ export class ParticipanteEventoService {
@InjectRepository(Evento) private eventoRepository: Repository<Evento>, @InjectRepository(Evento) private eventoRepository: Repository<Evento>,
@InjectRepository(Participante) @InjectRepository(Participante)
private participanteRepository: Repository<Participante>, private participanteRepository: Repository<Participante>,
@InjectRepository(Cuestionario)
private cuestionarioRepository: Repository<Cuestionario>,
@InjectRepository(EventoUsuario)
private eventoUsuarioRepository: Repository<EventoUsuario>,
@InjectRepository(RespuestaParticipanteAbierta)
private respuestaAbiertaRepository: Repository<RespuestaParticipanteAbierta>,
private readonly cuestionarioService: CuestionarioService, private readonly cuestionarioService: CuestionarioService,
private readonly qrTokenService: QrTokenService, private readonly qrTokenService: QrTokenService,
) {} ) {}
@@ -160,10 +170,9 @@ export class ParticipanteEventoService {
return this.participanteEventoRepository.save(participante_evento); return this.participanteEventoRepository.save(participante_evento);
} }
async registrarAsistencia( async registrarAsistenciaSinToken(
id_participante: number, id_participante: number,
id_cuestionario: number, id_cuestionario: number,
token: string,
) { ) {
const participante_eventoFound = const participante_eventoFound =
await this.participanteEventoRepository.findOne({ await this.participanteEventoRepository.findOne({
@@ -179,7 +188,55 @@ export class ParticipanteEventoService {
participante_eventoFound.fecha_asistencia = new Date(); participante_eventoFound.fecha_asistencia = new Date();
participante_eventoFound.asistio = true; participante_eventoFound.asistio = true;
return this.participanteEventoRepository.save(participante_eventoFound); const resultado =
await this.participanteEventoRepository.save(participante_eventoFound);
const cuestionario = await this.cuestionarioRepository.findOne({
where: { id_cuestionario },
});
let contador: number | null | undefined = undefined;
if (cuestionario?.id_evento) {
const tieneListaRestringida =
(await this.eventoUsuarioRepository.count({
where: { id_evento: cuestionario.id_evento },
})) > 0;
if (tieneListaRestringida) {
const respuesta =
(await this.respuestaAbiertaRepository.findOne({
where: {
cuestionarioRespondido: {
participante: { id_participante },
cuestionario: { id_cuestionario },
},
pregunta: { validacion: TiposValidacion.CUENTA_ALUMNO },
},
relations: ['pregunta', 'cuestionarioRespondido'],
})) ??
(await this.respuestaAbiertaRepository.findOne({
where: {
cuestionarioRespondido: {
participante: { id_participante },
cuestionario: { id_cuestionario },
},
pregunta: { validacion: TiposValidacion.RFC },
},
relations: ['pregunta', 'cuestionarioRespondido'],
}));
if (respuesta) {
const identificador = respuesta.respuesta.trim().toUpperCase();
const eventoUsuario = await this.eventoUsuarioRepository.findOne({
where: { id_evento: cuestionario.id_evento, identificador },
});
contador = eventoUsuario?.contador ?? null;
}
}
}
return { ...resultado, contador };
} }
/** /**
@@ -189,16 +246,18 @@ export class ParticipanteEventoService {
*/ */
async registrarAsistenciaConToken(qrToken: string) { async registrarAsistenciaConToken(qrToken: string) {
// Validar el token QR // Validar el token QR
const tokenData = this.qrTokenService.validateQrToken(qrToken); const tokenData = await this.qrTokenService.validateQrToken(qrToken);
if (!tokenData) { if (!tokenData) {
throw new HttpException( return{
'Token QR inválido o expirado', success: false,
HttpStatus.BAD_REQUEST, message:
); 'El token ya caduco',
data: {}
} }
}
const { id_participante, id_evento, id_cuestionario } = tokenData;
const { id_participante, id_evento, id_cuestionario, contador } = tokenData;
// Verificar que el participante existe // Verificar que el participante existe
const participante = await this.participanteRepository.findOne({ const participante = await this.participanteRepository.findOne({
@@ -231,13 +290,14 @@ export class ParticipanteEventoService {
// Verificar si ya había registrado asistencia // Verificar si ya había registrado asistencia
if (participanteEvento.asistio && participanteEvento.fecha_asistencia) { if (participanteEvento.asistio && participanteEvento.fecha_asistencia) {
return { return {
success: true, success: false,
message: message:
'El participante ya había registrado su asistencia previamente', 'El participante ya había registrado su asistencia previamente',
data: { data: {
participante: participante.correo, participante: participante.correo,
fecha_asistencia_previa: participanteEvento.fecha_asistencia, fecha_asistencia_previa: participanteEvento.fecha_asistencia,
fecha_actual: new Date(), fecha_actual: new Date(),
contador,
}, },
}; };
} }
@@ -257,7 +317,19 @@ export class ParticipanteEventoService {
fecha_asistencia: resultado.fecha_asistencia, fecha_asistencia: resultado.fecha_asistencia,
id_evento, id_evento,
id_cuestionario, id_cuestionario,
contador,
}, },
}; };
} }
async validToken(qrToken: string) {
// Validar el token QR
const tokenData = this.qrTokenService.validateQrToken(qrToken);
if (!tokenData) {
return null;
}
return tokenData
}
} }
+117 -7
View File
@@ -1,12 +1,30 @@
import { Injectable } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Asistencia } from 'src/asistencia/entities/asistencia.entity';
import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity';
import { Evento } from 'src/evento/entities/evento.entity';
import { EventoUsuario } from 'src/evento_usuario/entities/evento_usuario.entity';
import { RespuestaParticipanteAbierta } from 'src/respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
import { TiposValidacion } from 'src/pregunta/entities/pregunta.entity';
@Injectable() @Injectable()
export class QrTokenService { export class QrTokenService {
constructor( constructor(
private jwtService: JwtService, private jwtService: JwtService,
private configService: ConfigService, private configService: ConfigService,
@InjectRepository(Asistencia)
private readonly asistenciaRepository: Repository<Asistencia>,
@InjectRepository(Cuestionario)
private readonly cuestionarioRepository: Repository<Cuestionario>,
@InjectRepository(Evento)
private readonly eventoREpo: Repository<Evento>,
@InjectRepository(EventoUsuario)
private readonly eventoUsuarioRepo: Repository<EventoUsuario>,
@InjectRepository(RespuestaParticipanteAbierta)
private readonly respuestaAbiertaRepo: Repository<RespuestaParticipanteAbierta>,
) {} ) {}
/** /**
@@ -20,6 +38,7 @@ export class QrTokenService {
id_participante: number, id_participante: number,
id_evento: number, id_evento: number,
id_cuestionario: number, id_cuestionario: number,
fecha_fin: Date,
): string { ): string {
const payload = { const payload = {
id_participante, id_participante,
@@ -34,9 +53,20 @@ export class QrTokenService {
this.configService.get<string>('QR_JWT_SECRET') || this.configService.get<string>('QR_JWT_SECRET') ||
this.configService.get<string>('JWT_SECRET', 'tu_clave_secreta'); this.configService.get<string>('JWT_SECRET', 'tu_clave_secreta');
console.log('fecha de caducidad:', fecha_fin);
// Convert fecha_fin to seconds from now for expiresIn
const expiresInSeconds = Math.floor(
(fecha_fin.getTime() - Date.now()) / 1000,
);
console.log('expiresInSeconds:', expiresInSeconds);
console.log(
'Caducidad token: ',
Math.floor((fecha_fin.getTime() - Date.now()) / 1000),
);
return this.jwtService.sign(payload, { return this.jwtService.sign(payload, {
secret, secret,
expiresIn: '30d', // El QR puede ser válido por 30 días expiresIn: expiresInSeconds > 0 ? expiresInSeconds : 3600, // El QR puede ser válido por 30 días
}); });
} }
@@ -45,34 +75,114 @@ export class QrTokenService {
* @param token Token JWT a validar * @param token Token JWT a validar
* @returns Datos decodificados del token o null si es inválido * @returns Datos decodificados del token o null si es inválido
*/ */
validateQrToken(token: string): { async validateQrToken(token: string): Promise<{
id_participante: number; id_participante: number;
id_evento: number; id_evento: number;
id_cuestionario: number; id_cuestionario: number;
type: string; type: string;
iat: number; iat: number;
} | null { exp?: number;
contador?: number | null;
} | null> {
try { try {
const secret = const secret =
this.configService.get<string>('QR_JWT_SECRET') || this.configService.get<string>('QR_JWT_SECRET') ||
this.configService.get<string>('JWT_SECRET', 'tu_clave_secreta'); this.configService.get<string>('JWT_SECRET', 'tu_clave_secreta');
console.log(secret);
const decoded = this.jwtService.verify(token, { secret }); const decoded = this.jwtService.verify(token, { secret });
// Verificar que sea un token de tipo QR de asistencia console.log('este es el token', decoded);
const evento = await this.eventoREpo.findOne({
where: { id_evento: decoded.id_evento },
});
if (!evento) {
throw new UnauthorizedException('Evento no encontrado');
}
const fechaFinEvento = new Date(evento.fecha_fin);
const now = new Date();
if (fechaFinEvento.getTime() < now.getTime()) {
throw new Error('El evento ha finalizado');
}
if (decoded.type !== 'qr_asistencia') { if (decoded.type !== 'qr_asistencia') {
return null; return null;
} }
return { const result: {
id_participante: number;
id_evento: number;
id_cuestionario: number;
type: string;
iat: number;
contador?: number | null;
exp: number;
} = {
id_participante: decoded.id_participante, id_participante: decoded.id_participante,
id_evento: decoded.id_evento, id_evento: decoded.id_evento,
id_cuestionario: decoded.id_cuestionario, id_cuestionario: decoded.id_cuestionario,
type: decoded.type, type: decoded.type,
iat: decoded.iat, iat: decoded.iat,
exp: decoded.exp,
}; };
console.log({ result });
const tieneListaRestringida =
(await this.eventoUsuarioRepo.count({
where: { id_evento: decoded.id_evento },
})) > 0;
if (tieneListaRestringida) {
const respuesta = await this.respuestaAbiertaRepo.findOne({
where: {
cuestionarioRespondido: {
participante: { id_participante: decoded.id_participante },
cuestionario: { id_cuestionario: decoded.id_cuestionario },
},
pregunta: {
validacion: TiposValidacion.CUENTA_ALUMNO,
},
},
relations: ['pregunta', 'cuestionarioRespondido'],
});
const respuestaRfc = respuesta
? null
: await this.respuestaAbiertaRepo.findOne({
where: {
cuestionarioRespondido: {
participante: { id_participante: decoded.id_participante },
cuestionario: { id_cuestionario: decoded.id_cuestionario },
},
pregunta: {
validacion: TiposValidacion.RFC,
},
},
relations: ['pregunta', 'cuestionarioRespondido'],
});
const identificadorRespuesta = respuesta ?? respuestaRfc;
if (identificadorRespuesta) {
const identificador = identificadorRespuesta.respuesta
.trim()
.toUpperCase();
const eventoUsuario = await this.eventoUsuarioRepo.findOne({
where: { id_evento: decoded.id_evento, identificador },
});
result.contador = eventoUsuario?.contador ?? null;
}
}
return result;
} catch (error) { } catch (error) {
console.error('Error validando token QR:', error.message); console.error('Error validando token QR:', error);
return null; return null;
} }
} }
+5 -2
View File
@@ -69,20 +69,23 @@ export class QrController {
}; };
} }
@Get('generate-token/:idParticipante/:idEvento/:idCuestionario') @Get('generate-token/:idParticipante/:idEvento/:idCuestionario/:fechaFin')
@ApiOperation({ summary: 'Genera un token JWT para QR de asistencia' }) @ApiOperation({ summary: 'Genera un token JWT para QR de asistencia' })
@ApiParam({ name: 'idParticipante', description: 'ID del participante' }) @ApiParam({ name: 'idParticipante', description: 'ID del participante' })
@ApiParam({ name: 'idEvento', description: 'ID del evento' }) @ApiParam({ name: 'idEvento', description: 'ID del evento' })
@ApiParam({ name: 'idCuestionario', description: 'ID del cuestionario' }) @ApiParam({ name: 'idCuestionario', description: 'ID del cuestionario' })
@ApiParam({ name: 'fechaFin', description: 'Fecha de fin de validez del token (ISO 8601)' })
async generateQrToken( async generateQrToken(
@Param('idParticipante', ParseIntPipe) idParticipante: number, @Param('idParticipante', ParseIntPipe) idParticipante: number,
@Param('idEvento', ParseIntPipe) idEvento: number, @Param('idEvento', ParseIntPipe) idEvento: number,
@Param('idCuestionario', ParseIntPipe) idCuestionario: number, @Param('idCuestionario', ParseIntPipe) idCuestionario: number,
@Param('fechaFin') fechaFin: Date,
) { ) {
const token = this.qrTokenService.generateQrToken( const token = await this.qrTokenService.generateQrToken(
idParticipante, idParticipante,
idEvento, idEvento,
idCuestionario, idCuestionario,
fechaFin
); );
return { return {
+6 -1
View File
@@ -6,9 +6,14 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { Qr } from './qr.entity'; import { Qr } from './qr.entity';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { Asistencia } from 'src/asistencia/entities/asistencia.entity';
import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity';
import { Evento } from 'src/evento/entities/evento.entity';
import { EventoUsuario } from 'src/evento_usuario/entities/evento_usuario.entity';
import { RespuestaParticipanteAbierta } from 'src/respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Qr]), AuthModule, ConfigModule], imports: [TypeOrmModule.forFeature([Qr, Asistencia, Cuestionario, Evento, EventoUsuario, RespuestaParticipanteAbierta]), AuthModule, ConfigModule],
controllers: [QrController], controllers: [QrController],
providers: [QrService, QrTokenService], providers: [QrService, QrTokenService],
exports: [QrTokenService], exports: [QrTokenService],
+8 -2
View File
@@ -9,6 +9,7 @@ import {
UseInterceptors, UseInterceptors,
UploadedFile, UploadedFile,
NotFoundException, NotFoundException,
Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { TrabajadoresService } from './trabajadores.service'; import { TrabajadoresService } from './trabajadores.service';
import { CreateTrabajadoreDto } from './dto/create-trabajadore.dto'; import { CreateTrabajadoreDto } from './dto/create-trabajadore.dto';
@@ -46,8 +47,13 @@ export class TrabajadoresController {
} }
@Get(':rfc') @Get(':rfc')
findByRfc(@Param('rfc') rfc: string) { async findByRfc(
const trabajador = this.trabajadoresService.findByRfc(rfc); @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) { if (!trabajador) {
throw new NotFoundException(`No se encontró trabajador con RFC ${rfc}`); throw new NotFoundException(`No se encontró trabajador con RFC ${rfc}`);
} }
+2
View File
@@ -3,10 +3,12 @@ import { TrabajadoresService } from './trabajadores.service';
import { TrabajadoresController } from './trabajadores.controller'; import { TrabajadoresController } from './trabajadores.controller';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { RegistroTrabajador } from './entities/trabajadore.entity'; import { RegistroTrabajador } from './entities/trabajadore.entity';
import { EventoUsuarioModule } from '../evento_usuario/evento_usuario.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([RegistroTrabajador], 'alumnosConnection'), TypeOrmModule.forFeature([RegistroTrabajador], 'alumnosConnection'),
EventoUsuarioModule,
], ],
controllers: [TrabajadoresController], controllers: [TrabajadoresController],
providers: [TrabajadoresService], providers: [TrabajadoresService],
+33 -11
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable } from '@nestjs/common';
import { CreateTrabajadoreDto } from './dto/create-trabajadore.dto'; import { CreateTrabajadoreDto } from './dto/create-trabajadore.dto';
import { UpdateTrabajadoreDto } from './dto/update-trabajadore.dto'; import { UpdateTrabajadoreDto } from './dto/update-trabajadore.dto';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
@@ -7,12 +7,14 @@ import { Repository } from 'typeorm';
import * as fs from 'fs'; import * as fs from 'fs';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { UsuarioData } from 'src/alumnos/alumnos.service'; import { UsuarioData } from 'src/alumnos/alumnos.service';
import { EventoUsuarioService } from '../evento_usuario/evento_usuario.service';
@Injectable() @Injectable()
export class TrabajadoresService { export class TrabajadoresService {
constructor( constructor(
@InjectRepository(RegistroTrabajador, 'alumnosConnection') @InjectRepository(RegistroTrabajador, 'alumnosConnection')
private readonly trabajadorRepo: Repository<RegistroTrabajador>, private readonly trabajadorRepo: Repository<RegistroTrabajador>,
private readonly eventoUsuarioService: EventoUsuarioService,
) {} ) {}
async procesarArchivo( async procesarArchivo(
@@ -69,21 +71,41 @@ export class TrabajadoresService {
return { insertados, omitidos }; return { insertados, omitidos };
} }
async findByRfc(rfc: string): Promise<UsuarioData | null> { async findByRfc(
rfc: string,
id_evento?: number,
): Promise<UsuarioData | null> {
const trabajador = await this.trabajadorRepo.findOne({ const trabajador = await this.trabajadorRepo.findOne({
where: { rfc: rfc.trim().toUpperCase() }, where: { rfc: rfc.trim().toUpperCase() },
}); });
return { if (!trabajador) return null;
cuenta: trabajador?.num_trabajador?.toString() || null,
nombre: trabajador?.nombre || null, if (id_evento !== undefined) {
apellidos: trabajador const tieneLista =
? `${trabajador.apellidos}`.trim() await this.eventoUsuarioService.eventoTieneListaRestringida(id_evento);
: null,
carrera: trabajador?.carrera || null, if (tieneLista) {
genero: trabajador?.genero || null, const enLista = await this.eventoUsuarioService.identificadorEnLista(
rfc: trabajador?.rfc || null, // Incluye RFC en el retorno 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) { create(createTrabajadoreDto: CreateTrabajadoreDto) {