diff --git a/src/cuestionario/cuestionario.controller.ts b/src/cuestionario/cuestionario.controller.ts index 5654e23..ac2d9f0 100644 --- a/src/cuestionario/cuestionario.controller.ts +++ b/src/cuestionario/cuestionario.controller.ts @@ -11,6 +11,7 @@ import { UploadedFile, UnprocessableEntityException, UploadedFiles, + BadRequestException, } from '@nestjs/common'; import { CuestionarioService } from './cuestionario.service'; import { CreateCuestionarioDto } from './dto/create-cuestionario.dto'; @@ -35,17 +36,6 @@ export class CuestionarioController { return this.cuestionarioService.create(createCuestionarioDto); } - - private mapTipoPregunta(tipo: string): TipoPreguntaEnum { - 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}`); - } - } - @Post('banners/bulk') @UseInterceptors( FilesInterceptor('banners', 20, { // puedes subir hasta 20 imágenes @@ -67,8 +57,7 @@ export class CuestionarioController { } }, }), - ) - async subirBanners(@UploadedFiles() files: Express.Multer.File[]) { + )async subirBanners(@UploadedFiles() files: Express.Multer.File[]) { if (!files || files.length === 0) { throw new UnprocessableEntityException('No se subió ningún archivo'); } @@ -81,111 +70,23 @@ export class CuestionarioController { })); } - // 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; - } + @Post('carga-masiva') +@UseInterceptors(FileInterceptor('file')) +async cargaMasiva(@UploadedFile() file: Express.Multer.File) { + if (!file) { + throw new BadRequestException('No se ha subido ningún archivo'); } - // Función principal de carga masiva - async cargarEventosDesdeExcel(filePath: string) { - const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.readFile(filePath); + const cuestionarios = await this.cargaMasiva(file); - 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'); + return { + message: 'Carga masiva exitosa', + total: cuestionarios.length, + data: cuestionarios, + }; +} - 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), - }; - - // 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) { - // 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 })) || []; - - 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 - secciones, - }, - }; - - // Llamada a tu servicio para guardar en DB - await this.cuestionarioService.createCuestionarioEvento(createDto); - } - } - - return { message: 'Carga masiva completada' }; - } @Post('withEvento') @CuestionarioApiDocumentation.ApiCreateWithEvento diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index a4ff6c8..fe01f30 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -2,6 +2,7 @@ import { Injectable, InternalServerErrorException, NotFoundException, + UnprocessableEntityException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/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 { Seccion } from '../seccion/entities/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 { Opcion } from '../opcion/entities/opcion.entity'; import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity'; @@ -24,6 +25,8 @@ import { import { Evento } from '../evento/entities/evento.entity'; import { EventoService } from '../evento/evento.service'; import { CreateEventoWithCuestionarioDto } from './dto/create-evento-cuestionario.dto'; +import * as ExcelJS from 'exceljs'; +import { PLANTILLAS } from './plantillas'; @Injectable() export class CuestionarioService { @@ -50,6 +53,153 @@ export class CuestionarioService { private eventoService: EventoService, ) {} + + + private mapTipoPregunta(tipo: string): TipoPreguntaEnum { + 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 = { + 1: 'registro-comunidad-alumnos', + 2: 'registro-comunidad-trabajadores', + 3: 'registro-general', + }; + + 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(9).value ? Number(row.getCell(8).value) : null; + + + if (plantilla && PLANTILLA_MAP[plantilla]) { + const fecha_inicio = new Date(qRow.getCell(4).value as string); + const fecha_fin = new Date(qRow.getCell(5).value as string); + 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`); + return { + ...base.datos, + fecha_inicio, + fecha_fin, + id_tipo_evento, + + + }; + } + + + // 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 })) || []; + + 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 + secciones, + }, + }; + + // Llamada a tu servicio para guardar en DB + await this.createCuestionarioEvento(createDto); + } + } + + return { message: 'Carga masiva completada' }; + } + async create(createCuestionarioDto: CreateCuestionarioDto) { // Verificar que el evento exista await this.eventoService.getEventoOrFail(createCuestionarioDto.id_evento); diff --git a/src/cuestionario/plantillas.ts b/src/cuestionario/plantillas.ts new file mode 100644 index 0000000..6a359d9 --- /dev/null +++ b/src/cuestionario/plantillas.ts @@ -0,0 +1,123 @@ +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: 'Cerrada', + opciones: [{ valor: 'Si' }, { valor: 'No' }], + obligatoria: true, + validacion: 'comunidad_trabajador', + }, + { titulo: 'RFC', tipo: 'Abierta (Respuesta corta)', obligatoria: false, validacion: 'rfc' }, + { titulo: 'Correo electrónico', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'correo' }, + { titulo: 'Nombre(s)', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'nombre' }, + { titulo: 'Apellidos', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'apellidos' }, + { + titulo: 'Género', + tipo: 'Cerrada', + obligatoria: true, + validacion: 'genero', + opciones: [ + { valor: 'Masculino' }, + { valor: 'Femenino' }, + { valor: 'Prefiero no decirlo' }, + ], + }, + { titulo: 'Carrera', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: '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: 'Cerrada', + opciones: [{ valor: 'Si' }, { valor: 'No' }], + obligatoria: true, + validacion: 'comunidad_alumno', + }, + { titulo: 'Numero de cuenta', tipo: 'Abierta (Respuesta corta)', obligatoria: false, validacion: 'cuenta_alumno' }, + { titulo: 'Correo electrónico', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'correo' }, + { titulo: 'Nombre(s)', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'nombre' }, + { titulo: 'Apellidos', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'apellidos' }, + { + titulo: 'Género', + tipo: 'Cerrada', + obligatoria: true, + validacion: 'genero', + opciones: [ + { valor: 'Masculino' }, + { valor: 'Femenino' }, + { valor: 'Prefiero no decirlo' }, + ], + }, + { titulo: 'Institución de procedencia', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'institucion' }, + { titulo: 'Carrera', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: '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: 'Abierta (Respuesta corta)', obligatoria: false }, + { titulo: 'Correo electrónico', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'correo' }, + { titulo: 'Nombre(s)', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'nombre' }, + { titulo: 'Apellidos', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'apellidos' }, + { + titulo: 'Tipo de Asistente', + tipo: 'Cerrada', + obligatoria: true, + opciones: [ + { valor: 'Alumno' }, + { valor: 'Profesor' }, + { valor: 'Trabajador' }, + ], + }, + ], + }, + ], + }, + }, +]; diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index 9f3f426..0de8073 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -112,10 +112,11 @@ export class CuestionarioRespondidoService { if (cuestionario && cuestionario.evento) { try { // Generar token para el QR - qrToken = this.qrTokenService.generateQrToken( + qrToken = await this.qrTokenService.generateQrToken( participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, + ); // Generar código QR con el token @@ -336,10 +337,11 @@ export class CuestionarioRespondidoService { } // Generar token para el QR - qrToken = this.qrTokenService.generateQrToken( + qrToken = await this.qrTokenService.generateQrToken( participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, + ); qrBuffer = await QRCode.toBuffer(qrToken); diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index 39d5309..189b182 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -4,6 +4,7 @@ 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'; @Injectable() export class QrTokenService { @@ -12,6 +13,8 @@ export class QrTokenService { private configService: ConfigService, @InjectRepository(Asistencia) private readonly asistenciaRepository: Repository, + @InjectRepository(Cuestionario) + private readonly cuestionarioRepository: Repository, ) {} /** @@ -21,11 +24,11 @@ export class QrTokenService { * @param id_cuestionario ID del cuestionario * @returns Token JWT codificado */ - generateQrToken( + async generateQrToken( id_participante: number, id_evento: number, id_cuestionario: number, - ): string { + ): Promise { const payload = { id_participante, id_evento, @@ -39,9 +42,20 @@ export class QrTokenService { this.configService.get('QR_JWT_SECRET') || this.configService.get('JWT_SECRET', 'tu_clave_secreta'); + const fecha_fin = await this.cuestionarioRepository.findOne({ + where: { id_cuestionario }, + select: ['fecha_fin'], + }); + + if (!fecha_fin) { + throw new UnauthorizedException('Cuestionario no encontrado'); + } + // Convert fecha_fin to seconds from now for expiresIn + const expiresInSeconds = Math.floor((fecha_fin?.fecha_fin.getTime() - Date.now()) / 1000); + return this.jwtService.sign(payload, { secret, - expiresIn: '30d', // El QR puede ser válido por 30 días + expiresIn: expiresInSeconds > 0 ? expiresInSeconds : 0, // El QR puede ser válido por 30 días }); } diff --git a/src/qr/qr.controller.ts b/src/qr/qr.controller.ts index bb6246c..b435d5f 100644 --- a/src/qr/qr.controller.ts +++ b/src/qr/qr.controller.ts @@ -79,7 +79,7 @@ export class QrController { @Param('idEvento', ParseIntPipe) idEvento: number, @Param('idCuestionario', ParseIntPipe) idCuestionario: number, ) { - const token = this.qrTokenService.generateQrToken( + const token = await this.qrTokenService.generateQrToken( idParticipante, idEvento, idCuestionario, diff --git a/src/qr/qr.module.ts b/src/qr/qr.module.ts index d1eb140..9960373 100644 --- a/src/qr/qr.module.ts +++ b/src/qr/qr.module.ts @@ -7,9 +7,10 @@ import { Qr } from './qr.entity'; import { AuthModule } from '../auth/auth.module'; import { ConfigModule } from '@nestjs/config'; import { Asistencia } from 'src/asistencia/entities/asistencia.entity'; +import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Qr, Asistencia]), AuthModule, ConfigModule], + imports: [TypeOrmModule.forFeature([Qr, Asistencia, Cuestionario]), AuthModule, ConfigModule], controllers: [QrController], providers: [QrService, QrTokenService], exports: [QrTokenService],