Se creo un evento por xlsx

This commit is contained in:
evenegas
2025-10-01 10:14:27 -06:00
parent bbdeab629d
commit 919d94cb0e
7 changed files with 312 additions and 121 deletions
+14 -113
View File
@@ -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
+151 -1
View File
@@ -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<number, string> = {
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);
+123
View File
@@ -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' },
],
},
],
},
],
},
},
];
@@ -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);
+17 -3
View File
@@ -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<Asistencia>,
@InjectRepository(Cuestionario)
private readonly cuestionarioRepository: Repository<Cuestionario>,
) {}
/**
@@ -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<string> {
const payload = {
id_participante,
id_evento,
@@ -39,9 +42,20 @@ export class QrTokenService {
this.configService.get<string>('QR_JWT_SECRET') ||
this.configService.get<string>('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
});
}
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -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],