35 Commits

Author SHA1 Message Date
Your Name 297e9913bb reporte csv 2025-04-07 15:06:03 -06:00
eithan 60127068aa Merge branch 'master' into develop 2025-04-07 11:36:07 -06:00
jalvarado 766a380f98 fix: regex expression 2025-04-07 11:00:21 -06:00
eithan d6d2c1c0d4 Merge pull request 'eithan' (#19) from eithan into develop
Reviewed-on: #19
2025-04-07 09:38:59 -06:00
eithan 9997faadf9 Merge branch 'develop' into eithan 2025-04-07 09:38:53 -06:00
Your Name e3b2d3cb9c otro merge
Merge branch 'eithan' of https://repositorio.acatlan.unam.mx/CIDWA/formularios_api into eithan
2025-04-07 09:38:14 -06:00
Your Name c8667dc142 for root async, config module global 2025-04-07 09:38:08 -06:00
eithan 0805cffbab Merge pull request 'eithan' (#18) from eithan into develop
Reviewed-on: #18
2025-04-07 09:11:12 -06:00
eithan 973eb625aa Merge branch 'develop' into eithan 2025-04-07 09:11:07 -06:00
Your Name f9cdf6a593 no se por que merge
Merge branch 'eithan' of https://repositorio.acatlan.unam.mx/CIDWA/formularios_api into eithan
2025-04-07 09:10:21 -06:00
Your Name abbbb206cf number en puerto 2025-04-07 09:10:04 -06:00
eithan 91cc3f4e10 Merge pull request 'chage env for alumnos' (#17) from eithan into develop
Reviewed-on: #17
2025-04-07 09:07:42 -06:00
eithan f1ef5c0b3c Merge branch 'develop' into eithan 2025-04-07 09:07:35 -06:00
Your Name 61b2a50c65 chage env for alumnos 2025-04-07 09:06:58 -06:00
eithan 6ce45d5c1a Merge pull request 'develop' (#16) from develop into master
Reviewed-on: #16
2025-04-07 08:43:45 -06:00
eithan 8d1a9d3d53 Merge pull request 'endpoint alumnos' (#15) from eithan into develop
Reviewed-on: #15
2025-04-07 08:42:54 -06:00
Your Name 24e07ea2e5 endpoint alumnos 2025-04-07 08:41:54 -06:00
eithan 0c8981c81d Merge pull request 'eithan' (#14) from eithan into develop
Reviewed-on: #14
2025-04-04 14:43:54 -06:00
eithan fdeb4e557f Merge branch 'develop' into eithan 2025-04-04 14:41:24 -06:00
Your Name 4e992c5b6e buscando conflictos
Merge branch 'eithan' of https://repositorio.acatlan.unam.mx/CIDWA/formularios_api into eithan
2025-04-04 14:40:03 -06:00
Your Name e5c6f611f4 docker compose front, modulo de validacion de preguntas, crud de participante, get participantes por evento y evento por participantes 2025-04-04 14:39:04 -06:00
eithan d34fa32672 Merge pull request 'respuestas cerradas y multiples por id, limite de respuestas abiertas a 250' (#13) from eithan into develop
Reviewed-on: #13
2025-04-03 15:00:36 -06:00
eithan 521adac6bf Merge branch 'develop' into eithan 2025-04-03 15:00:29 -06:00
Your Name bc07c307cf respuestas cerradas y multiples por id, limite de respuestas abiertas a 250 2025-04-03 14:59:46 -06:00
eithan c8056c5957 Merge pull request 'swagger server doc' (#12) from eithan into develop
Reviewed-on: #12
2025-04-03 14:34:12 -06:00
Your Name 8208ff0292 swagger server doc 2025-04-03 14:31:53 -06:00
eithan 934b4e4ffe Merge pull request 'generar y enviar qr por correo electronico despues de registrar un formulario' (#11) from eithan into develop
Reviewed-on: #11
2025-04-03 14:11:59 -06:00
eithan 1e58a64cde Merge branch 'develop' into eithan 2025-04-03 14:11:53 -06:00
Your Name 5ba3f0f2a8 generar y enviar qr por correo electronico despues de registrar un formulario 2025-04-03 11:29:46 -06:00
stern f50c9f36c8 Establecer a versión node 22 en script start 2025-04-02 16:09:51 -06:00
eithan fa6a73ea1a Merge pull request 'registrar el evento cuando se crea en el formulario' (#10) from eithan into develop
Reviewed-on: #10
2025-04-02 15:58:02 -06:00
Your Name 8d191fbb23 registrar el evento cuando se crea en el formulario 2025-04-02 15:56:34 -06:00
jalvarado 1e13a5fffd Merge pull request 'qr genereta' (#9) from mike into develop
Reviewed-on: #9
2025-04-02 13:55:49 -06:00
eithan 7c3aba670e Merge pull request 'cuestionario contestado, respuestas guardadas' (#8) from eithan into develop
Reviewed-on: #8
2025-04-02 13:52:46 -06:00
Your Name c4d4fed43d cuestionario contestado, respuestas guardadas 2025-04-02 13:51:04 -06:00
47 changed files with 2547 additions and 204 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
# Etapa de desarrollo para ejecutar en watch mode
FROM node:20-alpine
FROM node:22-alpine
WORKDIR /app
@@ -12,7 +12,7 @@ COPY . .
RUN npm run build
# Puerto expuesto
EXPOSE 4200
EXPOSE 4204
# Comando para iniciar en modo desarrollo
CMD ["npm", "run", "start:dev"]
+22
View File
@@ -97,3 +97,25 @@ Nest is an MIT-licensed open source project. It can grow thanks to the sponsors
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
# usar validadores en el front end
import { validarRespuesta, FabricaValidadores } from
'./validaciones/validador';
// To validate a single response
const respuesta = 'user@example.com';
const tipoValidacion = 'correo';
const resultado = validarRespuesta(respuesta, tipoValidacion);
if (resultado.valido) {
// proceed
} else {
// show error message: resultado.mensaje
}
// Or directly use the validators
const validadorCorreo = new ValidadorCorreo();
const resultadoCorreo = validadorCorreo.validar('user@example.com');
+16 -1
View File
@@ -11,6 +11,7 @@ services:
MARIADB_DATABASE: formularios_test
volumes:
- mariadb_data:/var/lib/mysql
- ./init-scripts:/docker-entrypoint-initdb.d
restart: unless-stopped
api:
@@ -24,10 +25,24 @@ services:
- .:/app
- /app/node_modules
ports:
- "4200:4200"
- "4204:4204"
depends_on:
- mariadb
restart: unless-stopped
front:
build: ../formularios_front
container_name: formularios_front
restart: always
depends_on:
- api
env_file:
- ../formularios_front/.env
ports:
- "4202:3000"
volumes:
mariadb_data:
+14
View File
@@ -0,0 +1,14 @@
PORT=4200
db_type=mysql
db_host=mariadb # <-- uso del nombre del servicio, NO IP
db_port=3306
db_username=root # <-- te conectarás con root
db_password=mypass # <-- la misma que MARIADB_ROOT_PASSWORD
db_database=formularios_test
url_api_correos=http://host.docker.internal:4100 #http://localhost:4100
+22
View File
@@ -0,0 +1,22 @@
-- Crear la base de datos para el registro de alumnos
CREATE DATABASE IF NOT EXISTS datos_feria_registro;
USE datos_feria_registro;
-- Crear la tabla registro_alumno
CREATE TABLE IF NOT EXISTS registro_alumno (
id_ncuenta int(9) unsigned zerofill NOT NULL,
nombre varchar(100) NOT NULL,
apellidos varchar(120) NOT NULL,
carrera varchar(100) NOT NULL,
genero char(1) NOT NULL,
PRIMARY KEY (id_ncuenta)
);
-- Insertar algunos datos de ejemplo
INSERT INTO registro_alumno (id_ncuenta, nombre, apellidos, carrera, genero) VALUES
(000123456, 'Juan', 'Pérez López', 'Ingeniería en Computación', 'M'),
(000789012, 'María', 'González Ramírez', 'Licenciatura en Administración', 'F'),
(000456789, 'Carlos', 'Rodríguez Martínez', 'Ingeniería Civil', 'M'),
(000345678, 'Ana', 'Sánchez Jiménez', 'Medicina', 'F'),
(000901234, 'Pedro', 'Fernández Gómez', 'Arquitectura', 'M'),
(316092940, 'Eithan', 'Fernández Gómez', 'Arquitectura', 'M');
+1 -1
View File
@@ -8,7 +8,7 @@
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start": "source ~/.nvm/nvm.sh && nvm use 22.10.0 && npm install && nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
+24
View File
@@ -0,0 +1,24 @@
import { Controller, Get, Param, NotFoundException } from '@nestjs/common';
import { AlumnosService } from './alumnos.service';
import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
import { AlumnoDto, ALUMNO_RESPONSES } from './alumnos.documentation';
@ApiTags('alumnos')
@Controller('alumnos')
export class AlumnosController {
constructor(private readonly alumnosService: AlumnosService) {}
@Get(':cuenta')
@ApiOperation({ summary: 'Obtener datos de un alumno por su número de cuenta' })
@ApiParam({ name: 'cuenta', description: 'Número de cuenta del alumno' })
@ApiResponse(ALUMNO_RESPONSES[200])
@ApiResponse(ALUMNO_RESPONSES[404])
@ApiResponse(ALUMNO_RESPONSES[500])
async getAlumnoByCuenta(@Param('cuenta') cuenta: string): Promise<AlumnoDto | null> {
const alumno = await this.alumnosService.findByCuenta(cuenta);
if (!alumno) {
throw new NotFoundException(`No se encontró alumno con número de cuenta ${cuenta}`);
}
return alumno;
}
}
+47
View File
@@ -0,0 +1,47 @@
import { ApiProperty } from '@nestjs/swagger';
export class AlumnoDto {
@ApiProperty({
example: 123456789,
description: 'Número de cuenta del alumno (9 dígitos)',
})
id_ncuenta: number;
@ApiProperty({
example: 'Juan',
description: 'Nombre del alumno',
})
nombre: string;
@ApiProperty({
example: 'Pérez López',
description: 'Apellidos del alumno',
})
apellidos: string;
@ApiProperty({
example: 'Ingeniería en Computación',
description: 'Carrera que cursa el alumno',
})
carrera: string;
@ApiProperty({
example: 'M',
description: 'Género del alumno (M: Masculino, F: Femenino)',
enum: ['M', 'F'],
})
genero: string;
}
export const ALUMNO_RESPONSES = {
200: {
description: 'Datos del alumno obtenidos correctamente',
type: AlumnoDto,
},
404: {
description: 'Alumno no encontrado',
},
500: {
description: 'Error interno del servidor',
},
};
+53
View File
@@ -0,0 +1,53 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AlumnosController } from './alumnos.controller';
import { AlumnosService } from './alumnos.service';
import { RegistroAlumno } from './entities/registro-alumno.entity';
import { ConfigService } from '@nestjs/config';
@Module({
imports: [
TypeOrmModule.forFeature([RegistroAlumno], 'alumnosConnection'),
TypeOrmModule.forRootAsync({
name: 'alumnosConnection',
useFactory: (configService: ConfigService) => ({
type: 'mysql',
host: configService.get<string>('ALUMNOS_DB_HOST'),
port: Number(configService.get<number>('ALUMNOS_DB_PORT')),
username: configService.get<string>('ALUMNOS_DB_USERNAME'),
password: configService.get<string>('ALUMNOS_DB_PASSWORD'),
database: configService.get<string>('ALUMNOS_DB_DATABASE'),
entities: [RegistroAlumno],
synchronize: false,
retryAttempts: 10,
retryDelay: 3000,
connectTimeout: 30000,
}),
inject: [ConfigService],
}),
/* TypeOrmModule.forRoot({
name: 'alumnosConnection',
type: 'mysql',
host: process.env.ALUMNOS_DB_HOST ,
port: Number(process.env.ALUMNOS_DB_PORT),
username: process.env.ALUMNOS_DB_USERNAME ,
password: process.env.ALUMNOS_DB_PASSWORD ,
database: process.env.ALUMNOS_DB_DATABASE ,
entities: [RegistroAlumno],
synchronize: false,
retryAttempts: 10, // Intentos para reconectar
retryDelay: 3000, // Tiempo entre reintentos
connectTimeout: 30000, // Timeout de conexión (30 segundos) │ │
}), */
],
controllers: [AlumnosController],
providers: [AlumnosService],
exports: [AlumnosService],
})
export class AlumnosModule {}
+18
View File
@@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RegistroAlumno } from './entities/registro-alumno.entity';
@Injectable()
export class AlumnosService {
constructor(
@InjectRepository(RegistroAlumno, 'alumnosConnection')
private registroAlumnoRepository: Repository<RegistroAlumno>,
) {}
async findByCuenta(cuenta: string): Promise<RegistroAlumno | null> {
return this.registroAlumnoRepository.findOne({
where: { id_ncuenta: parseInt(cuenta, 10) },
});
}
}
@@ -0,0 +1,19 @@
import { Entity, Column, PrimaryColumn } from 'typeorm';
@Entity('registro_alumno')
export class RegistroAlumno {
@PrimaryColumn({ type: 'int', unsigned: true, zerofill: true, width: 9 })
id_ncuenta: number;
@Column({ type: 'varchar', length: 100 })
nombre: string;
@Column({ type: 'varchar', length: 120 })
apellidos: string;
@Column({ type: 'varchar', length: 100 })
carrera: string;
@Column({ type: 'char', length: 1 })
genero: string;
}
+7 -1
View File
@@ -23,11 +23,15 @@ import { QrModule } from './qr/qr.module';
import { AdministradorModule } from './administrador/administrador.module';
import { AsistenciaModule } from './asistencia/asistencia.module';
import { ParticipanteEventoModule } from './participante_evento/participante_evento.module';
import { ValidacionesModule } from './validaciones/validaciones.module';
import { AlumnosModule } from './alumnos/alumnos.module';
@Module({
imports: [
ConfigModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
}),
TypeOrmModule.forRoot({
type: 'mysql',
@@ -77,6 +81,8 @@ import { ParticipanteEventoModule } from './participante_evento/participante_eve
AdministradorModule,
AsistenciaModule,
ParticipanteEventoModule,
ValidacionesModule,
AlumnosModule,
],
controllers: [AppController],
@@ -1,7 +1,7 @@
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger';
import { feriaSexualidad } from '../../utils/crear_formulario_feria';
import { applyDecorators } from '@nestjs/common';
import { FormularioDto } from './dto/formulario.schema';
import { FormularioDto, FormularioFeriaSchema } from './dto/formulario.schema';
export class CuestionarioApiDocumentation {
// Decoradores para toda la clase del controlador
@@ -11,7 +11,7 @@ export class CuestionarioApiDocumentation {
static ApiGetFormulario = applyDecorators(
ApiOperation({
summary: 'Obtener formulario completo',
description: 'Devuelve el cuestionario completo con la misma estructura que se usa para crearlo'
description: 'Devuelve el cuestionario completo con la misma estructura que se usa para crearlo, incluyendo el atributo validacion para las preguntas que tengan reglas de validación específicas'
}),
ApiParam({
name: 'id',
@@ -22,7 +22,7 @@ export class CuestionarioApiDocumentation {
}),
ApiResponse({
status: 200,
description: 'Formulario completo con todas sus secciones, preguntas y opciones',
description: 'Formulario completo con todas sus secciones, preguntas y opciones. Las preguntas incluyen el atributo "validacion" si tienen reglas de validación específicas (como "correo", "cuenta_alumno", "telefono", "nombre", etc.)',
type: FormularioDto,
content: {
'application/json': {
@@ -38,22 +38,22 @@ export class CuestionarioApiDocumentation {
static ApiCreate = applyDecorators(
ApiOperation({
summary: 'Crear un nuevo cuestionario',
description: 'Crea un nuevo cuestionario con secciones, preguntas y opciones'
description: 'Crea un nuevo cuestionario con secciones, preguntas y opciones, incluyendo validaciones personalizadas'
}),
ApiBody({
description: 'Datos del cuestionario a crear',
description: 'Datos del cuestionario a crear. Para las preguntas de tipo "Abierta", se puede especificar un tipo de validación usando la propiedad "validacion" con valores como: "correo", "cuenta_alumno", "telefono", "nombre", "entero", "decimal", etc.',
type: FormularioDto,
examples: {
feriaSexualidad: {
summary: 'Ejemplo de cuestionario para feria de sexualidad',
description: 'Cuestionario con secciones, preguntas y opciones para feria de sexualidad',
description: 'Cuestionario con secciones, preguntas y opciones para feria de sexualidad, ahora con validaciones específicas para cada tipo de campo (correo, nombre, cuenta_alumno, etc.)',
value: feriaSexualidad
}
}
}),
ApiResponse({
status: 201,
description: 'Cuestionario creado correctamente',
description: 'Cuestionario creado correctamente. Los campos con la propiedad "validacion" se validarán según su tipo: "correo", "cuenta_alumno", "telefono", "nombre", "entero", "decimal", etc.',
schema: {
properties: {
success: { type: 'boolean', example: true },
+5 -2
View File
@@ -11,6 +11,8 @@ import { Opcion } from '../opcion/entities/opcion.entity';
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
import { TipoPregunta } from '../tipo_pregunta/entities/tipo_pregunta.entity';
import { TipoCuestionario } from '../tipo_cuestionario/entities/tipo_cuestionario.entity';
import { Evento } from '../evento/evento.entity';
import { EventoService } from '../evento/evento.service';
@Module({
imports: [
@@ -23,11 +25,12 @@ import { TipoCuestionario } from '../tipo_cuestionario/entities/tipo_cuestionari
Opcion,
PreguntaOpcion,
TipoPregunta,
TipoCuestionario
TipoCuestionario,
Evento
])
],
controllers: [CuestionarioController],
providers: [CuestionarioService],
providers: [CuestionarioService, EventoService],
exports: [TypeOrmModule, CuestionarioService]
})
export class CuestionarioModule {}
+102 -17
View File
@@ -12,6 +12,8 @@ import { Opcion } from '../opcion/entities/opcion.entity';
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
import { TipoPregunta } from '../tipo_pregunta/entities/tipo_pregunta.entity';
import { TipoCuestionario } from '../tipo_cuestionario/entities/tipo_cuestionario.entity';
import { Evento } from '../evento/evento.entity';
import { EventoService } from '../evento/evento.service';
@Injectable()
export class CuestionarioService {
@@ -32,7 +34,10 @@ export class CuestionarioService {
private preguntaOpcionRepository: Repository<PreguntaOpcion>,
@InjectRepository(TipoPregunta)
private tipoPreguntaRepository: Repository<TipoPregunta>,
@InjectRepository(Evento)
private eventoRepository: Repository<Evento>,
private dataSource: DataSource,
private eventoService: EventoService
) {}
async create(createCuestionarioDto: CreateCuestionarioDto) {
@@ -41,6 +46,33 @@ export class CuestionarioService {
await queryRunner.startTransaction();
try {
// Verificar si se especificó un evento y búscarlo/crearlo
let idEvento: number | undefined = undefined;
if (createCuestionarioDto.evento) {
// Buscar si el evento ya existe
let evento = await this.eventoRepository.findOne({
where: { nombre_evento: createCuestionarioDto.evento }
});
// Si no existe, crearlo
if (!evento) {
const nuevoEvento = {
nombre_evento: createCuestionarioDto.evento,
tipo_evento: 'Predeterminado',
fecha_inicio: createCuestionarioDto.fecha_inicio || new Date(),
fecha_fin: createCuestionarioDto.fecha_fin || new Date(Date.now() + 86400000) // 1 día después si no hay fecha
};
evento = await queryRunner.manager.save(Evento, nuevoEvento);
console.log(`Evento creado: ${evento.nombre_evento}, ID: ${evento.id_evento}`);
} else {
console.log(`Evento encontrado: ${evento.nombre_evento}, ID: ${evento.id_evento}`);
}
idEvento = evento.id_evento;
}
// 1. Crear cuestionario
const cuestionario = new Cuestionario();
cuestionario.nombre_form = createCuestionarioDto.nombre_form;
@@ -51,6 +83,11 @@ export class CuestionarioService {
cuestionario.contador_secciones = createCuestionarioDto.secciones?.length || 0;
cuestionario.editable = true;
// Asociar el cuestionario con el evento si existe
if (idEvento !== undefined) {
cuestionario.id_evento = idEvento;
}
const savedCuestionario = await queryRunner.manager.save(cuestionario);
// 2. Crear secciones y vincularlas al cuestionario
@@ -79,15 +116,22 @@ export class CuestionarioService {
for (let j = 0; j < seccionDto.preguntas.length; j++) {
const preguntaDto = seccionDto.preguntas[j];
// Obtener el tipo de pregunta por su nombre
const tipoPregunta = await queryRunner.manager.findOne(TipoPregunta, {
where: { tipo_pregunta: preguntaDto.tipo },
});
// Mapear directamente el tipo de pregunta a su ID correspondiente
let idTipoPregunta = 7; // Valor por defecto (desconocido)
if (!tipoPregunta) {
throw new Error(`Tipo de pregunta '${preguntaDto.tipo}' no encontrado`);
// Mapeamos el tipo de pregunta a su ID correspondiente
if (preguntaDto.tipo === 'Cerrada') {
idTipoPregunta = 1;
} else if (preguntaDto.tipo === 'Abierta') {
idTipoPregunta = 2;
} else if (preguntaDto.tipo === 'Multiple') {
idTipoPregunta = 3;
} else {
console.warn(`Tipo de pregunta no reconocido: ${preguntaDto.tipo}. Se asignará como desconocido.`);
}
const tipoPregunta = { id_tipo: idTipoPregunta, tipo_pregunta: preguntaDto.tipo };
// Crear pregunta
const pregunta = new Pregunta();
pregunta.pregunta = preguntaDto.titulo;
@@ -95,6 +139,7 @@ export class CuestionarioService {
pregunta.id_tipo_pregunta = tipoPregunta.id_tipo;
pregunta.id_opcion_dependiente = preguntaDto.id_opcion_dependiente || undefined;
pregunta.contador_opcion = preguntaDto.opciones?.length || 0;
pregunta.validacion = preguntaDto.validacion || undefined;
const savedPregunta = await queryRunner.manager.save(pregunta);
@@ -165,7 +210,8 @@ export class CuestionarioService {
async findCompleto(id: number) {
// Obtener el cuestionario básico
const cuestionario = await this.cuestionarioRepository.findOne({
where: { id_cuestionario: id }
where: { id_cuestionario: id },
relations: ['evento'] // Incluir la relación con el evento
});
if (!cuestionario) {
@@ -179,6 +225,13 @@ export class CuestionarioService {
tipo_cuestionario: 'Encuesta'
};
// Mapeador de ID de tipo de pregunta a nombres conocidos
const tiposPreguntaMap = {
1: { id_tipo: 1, tipo_pregunta: 'Cerrada' },
2: { id_tipo: 2, tipo_pregunta: 'Abierta' },
3: { id_tipo: 3, tipo_pregunta: 'Multiple' }
};
// Obtener las relaciones cuestionario-seccion
const cuestionarioSecciones = await this.cuestionarioSeccionRepository.find({
where: { id_cuestionario: id },
@@ -216,14 +269,8 @@ export class CuestionarioService {
if (!pregunta) return null;
// Usar un mapeo hardcodeado para tipos de pregunta
const tiposPregunta = {
1: { id_tipo: 1, tipo_pregunta: 'Cerrada' },
2: { id_tipo: 2, tipo_pregunta: 'Abierta' },
3: { id_tipo: 3, tipo_pregunta: 'Multiple' }
};
const tipoPregunta = tiposPregunta[pregunta.id_tipo_pregunta] ||
// Usar el mapeo de tipos de pregunta definido arriba
const tipoPregunta = tiposPreguntaMap[pregunta.id_tipo_pregunta] ||
{ id_tipo: pregunta.id_tipo_pregunta, tipo_pregunta: 'Desconocido' };
// Obtener opciones para preguntas de tipo multiple/radio
@@ -255,6 +302,7 @@ export class CuestionarioService {
obligatoria: pregunta.obligatoria,
id_tipo_pregunta: pregunta.id_tipo_pregunta,
id_opcion_dependiente: pregunta.id_opcion_dependiente,
validacion: pregunta.validacion,
tipo_pregunta: {
id_tipo: tipoPregunta.id_tipo,
tipo_pregunta: tipoPregunta.tipo_pregunta
@@ -286,6 +334,13 @@ export class CuestionarioService {
id_tipo_cuestionario: tipoCuestionario.id_tipo_cuestionario,
tipo_cuestionario: tipoCuestionario.tipo_cuestionario
},
evento: cuestionario.evento ? {
id_evento: cuestionario.evento.id_evento,
nombre_evento: cuestionario.evento.nombre_evento,
tipo_evento: cuestionario.evento.tipo_evento,
fecha_inicio: cuestionario.evento.fecha_inicio ? cuestionario.evento.fecha_inicio.toISOString() : null,
fecha_fin: cuestionario.evento.fecha_fin ? cuestionario.evento.fecha_fin.toISOString() : null
} : null,
cuestionario: {
id_cuestionario: cuestionario.id_cuestionario,
nombre_form: cuestionario.nombre_form,
@@ -296,13 +351,43 @@ export class CuestionarioService {
fecha_fin: cuestionario.fecha_fin ? cuestionario.fecha_fin.toISOString() : null,
id_cuestionario_original: cuestionario.id_cuestionario_original,
id_tipo_cuestionario: cuestionario.id_tipo_cuestionario,
id_evento: cuestionario.id_evento || null,
secciones: seccionesFormateadas
}
};
}
update(id: number, updateCuestionarioDto: UpdateCuestionarioDto) {
return this.cuestionarioRepository.update(id, updateCuestionarioDto);
async update(id: number, updateCuestionarioDto: UpdateCuestionarioDto) {
// Si el DTO incluye el campo "evento" (string), debemos procesarlo aparte
if (updateCuestionarioDto.evento) {
// Buscar o crear evento
let evento = await this.eventoRepository.findOne({
where: { nombre_evento: updateCuestionarioDto.evento }
});
if (!evento) {
// Crear el evento
const nuevoEvento = {
nombre_evento: updateCuestionarioDto.evento,
tipo_evento: 'Predeterminado',
fecha_inicio: new Date(),
fecha_fin: new Date(Date.now() + 86400000)
};
evento = await this.eventoRepository.save(nuevoEvento);
}
// Asignar el ID del evento al DTO de actualización
updateCuestionarioDto.id_evento = evento.id_evento;
// Eliminar la propiedad evento para evitar conflictos
delete updateCuestionarioDto.evento;
}
// Preparar los datos para la actualización
const updateData: any = { ...updateCuestionarioDto };
return this.cuestionarioRepository.update(id, updateData);
}
remove(id: number) {
@@ -1,28 +1,66 @@
import { CreateSeccionDto } from '../../seccion/dto/create-seccion.dto';
import { IsArray, IsDateString, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';
export class CreateCuestionarioDto {
@ApiProperty({
description: 'Nombre del formulario',
example: 'Registro para la Feria de la Sexualidad - FES Acatlán'
})
@IsNotEmpty()
@IsString()
nombre_form: string;
@ApiProperty({
description: 'Descripción del formulario',
example: 'Formulario de registro para la feria...',
required: false
})
@IsOptional()
@IsString()
descripcion?: string;
@ApiProperty({
description: 'Fecha de inicio',
example: '2025-03-26T00:00:00',
required: false
})
@IsOptional()
@IsDateString()
fecha_inicio?: Date;
@ApiProperty({
description: 'Fecha de fin',
example: '2025-03-31T23:59:59',
required: false
})
@IsOptional()
@IsDateString()
fecha_fin?: Date;
@ApiProperty({
description: 'ID del tipo de cuestionario',
example: 1
})
@IsNotEmpty()
@IsNumber()
id_tipo_cuestionario: number;
@ApiProperty({
description: 'Nombre del evento asociado',
example: 'Feria de la Sexualidad',
required: false
})
@IsOptional()
@IsString()
evento?: string;
@ApiProperty({
description: 'Secciones del cuestionario',
type: [CreateSeccionDto],
required: false
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
+210 -84
View File
@@ -1,94 +1,220 @@
import { ApiProperty } from '@nestjs/swagger';
import { TipoPreguntaEnum } from '../../pregunta/dto/create-pregunta.dto';
export class OpcionDto {
@ApiProperty({
description: 'Valor de la opción',
example: 'Si'
})
valor: string;
}
export class PreguntaDto {
@ApiProperty({
description: 'Título de la pregunta',
example: '¿Eres parte de la comunidad de la FES Acatlán?'
})
titulo: string;
@ApiProperty({
description: 'Indica si la pregunta es obligatoria',
example: true
})
obligatoria: boolean;
@ApiProperty({
description: 'Tipo de pregunta (Texto, Numero, Multiple, Radio, Abierto, Fecha)',
example: 'Multiple'
})
tipo: string;
@ApiProperty({
description: 'Lista de opciones para preguntas de tipo Multiple o Radio',
type: [OpcionDto],
required: false
})
opciones?: OpcionDto[];
}
export class SeccionDto {
@ApiProperty({
description: 'Título de la sección',
example: 'Información Personal'
})
titulo: string;
@ApiProperty({
description: 'Descripción de la sección',
example: 'Proporciona tus datos personales para poder contactarte.'
})
descripcion?: string;
@ApiProperty({
description: 'Lista de preguntas que conforman la sección',
type: [PreguntaDto]
})
preguntas: PreguntaDto[];
}
// Ejemplo de respuesta de cuestionario completo
export const FormularioFeriaSchema = {
tipo_cuestionario: {
id_tipo_cuestionario: 1,
tipo_cuestionario: 'Encuesta'
},
cuestionario: {
id_cuestionario: 10,
nombre_form: 'Registro para la Feria de la Sexualidad - Comunidad FES Acatlán',
contador_secciones: 2,
descripcion: 'La Feria de la Sexualidad es un espacio seguro e informativo dirigido a toda la comunidad universitaria. Este registro nos ayudará a organizar mejor las actividades, talleres y charlas, así como conocer tus intereses. Por favor, completa el siguiente formulario para confirmar tu participación.',
editable: true,
fecha_inicio: '2025-03-26T00:00:00',
fecha_fin: '2025-03-31T23:59:59',
id_cuestionario_original: null,
id_tipo_cuestionario: 1,
secciones: [
{
id_cuestionario_seccion: 1,
posicion: 1,
seccion: {
id_seccion: 100,
contador_pregunta: 3,
descripcion: 'Información básica',
titulo: 'Datos personales'
},
preguntas: [
{
id_seccion_pregunta: 1000,
posicion: 1,
pregunta: {
id_pregunta: 101,
pregunta: '¿Eres parte de la comunidad de la FES Acatlán?',
contador_opcion: 2,
obligatoria: true,
id_tipo_pregunta: 1,
id_opcion_dependiente: null,
tipo_pregunta: {
id_tipo: 1,
tipo_pregunta: 'Cerrada'
},
opciones: [
{
id_pregunta_opcion: 50001,
posicion: 1,
id_opcion: 90001,
opcion: {
id_opcion: 90001,
opcion: 'Sí'
}
},
{
id_pregunta_opcion: 50002,
posicion: 2,
id_opcion: 90002,
opcion: {
id_opcion: 90002,
opcion: 'No'
}
}
]
}
},
{
id_seccion_pregunta: 1005,
posicion: 2,
pregunta: {
id_pregunta: 109,
pregunta: 'Correo electrónico',
contador_opcion: 0,
obligatoria: true,
id_tipo_pregunta: 2,
id_opcion_dependiente: null,
limite: 250, // Límite de caracteres para respuestas de texto
validacion: 'correo', // Validación de formato de correo electrónico
tipo_pregunta: {
id_tipo: 2,
tipo_pregunta: 'Abierta'
},
opciones: []
}
},
{
id_seccion_pregunta: 1001,
posicion: 3,
pregunta: {
id_pregunta: 102,
pregunta: 'Número de cuenta (solo para estudiantes de la FES Acatlán)',
contador_opcion: 0,
obligatoria: false,
id_tipo_pregunta: 2,
id_opcion_dependiente: 90001, // Dependiente de la respuesta "Sí" en la pregunta anterior
limite: 250, // Límite de caracteres para respuestas de texto
validacion: 'cuenta_alumno', // Validación de formato de número de cuenta
tipo_pregunta: {
id_tipo: 2,
tipo_pregunta: 'Abierta'
},
opciones: []
}
}
]
},
{
id_cuestionario_seccion: 2,
posicion: 2,
seccion: {
id_seccion: 101,
contador_pregunta: 2,
descripcion: 'Información sobre tus intereses',
titulo: 'Intereses'
},
preguntas: [
{
id_seccion_pregunta: 1003,
posicion: 1,
pregunta: {
id_pregunta: 106,
pregunta: '¿Qué temas te interesan para la Feria de la Sexualidad?',
contador_opcion: 5,
obligatoria: true,
id_tipo_pregunta: 3,
id_opcion_dependiente: null,
tipo_pregunta: {
id_tipo: 3,
tipo_pregunta: 'Multiple'
},
opciones: [
{
id_pregunta_opcion: 50010,
posicion: 1,
id_opcion: 90010,
opcion: {
id_opcion: 90010,
opcion: 'Educación sexual'
}
},
{
id_pregunta_opcion: 50011,
posicion: 2,
id_opcion: 90011,
opcion: {
id_opcion: 90011,
opcion: 'Prevención de ITS'
}
},
{
id_pregunta_opcion: 50012,
posicion: 3,
id_opcion: 90012,
opcion: {
id_opcion: 90012,
opcion: 'Salud reproductiva'
}
},
{
id_pregunta_opcion: 50013,
posicion: 4,
id_opcion: 90013,
opcion: {
id_opcion: 90013,
opcion: 'Diversidad sexual y de género'
}
},
{
id_pregunta_opcion: 50014,
posicion: 5,
id_opcion: 90014,
opcion: {
id_opcion: 90014,
opcion: 'Relaciones saludables'
}
}
]
}
},
{
id_seccion_pregunta: 1004,
posicion: 2,
pregunta: {
id_pregunta: 107,
pregunta: '¿Algún otro tema que te gustaría que se abordara?',
contador_opcion: 0,
obligatoria: false,
id_tipo_pregunta: 2,
id_opcion_dependiente: null,
limite: 250, // Límite de caracteres para respuestas de texto
tipo_pregunta: {
id_tipo: 2,
tipo_pregunta: 'Abierta'
},
opciones: []
}
}
]
}
]
}
};
// Ejemplo de tipo FormularioDto para usar en la API
export class FormularioDto {
@ApiProperty({
description: 'Nombre del formulario',
example: 'Registro para la Feria de la Sexualidad - FES Acatlán'
description: 'Tipo de cuestionario',
example: {
id_tipo_cuestionario: 1,
tipo_cuestionario: 'Encuesta'
}
})
nombre_form: string;
tipo_cuestionario: any;
@ApiProperty({
description: 'Descripción del formulario',
example: 'La Feria de la Sexualidad es un espacio seguro e informativo...'
description: 'Información completa del cuestionario',
example: FormularioFeriaSchema.cuestionario
})
descripcion?: string;
@ApiProperty({
description: 'Fecha de inicio del formulario',
example: '2025-03-07T00:00:01'
})
fecha_inicio?: string;
@ApiProperty({
description: 'Fecha de fin del formulario',
example: '2025-03-18T23:59:59'
})
fecha_fin?: string;
@ApiProperty({
description: 'ID del tipo de cuestionario',
example: 1
})
id_tipo_cuestionario: number;
@ApiProperty({
description: 'Secciones que conforman el formulario',
type: [SeccionDto]
})
secciones: SeccionDto[];
cuestionario: any;
}
@@ -1,4 +1,15 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateCuestionarioDto } from './create-cuestionario.dto';
import { IsNumber, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateCuestionarioDto extends PartialType(CreateCuestionarioDto) {}
export class UpdateCuestionarioDto extends PartialType(CreateCuestionarioDto) {
@ApiProperty({
description: 'ID del evento asociado',
example: 1,
required: false
})
@IsOptional()
@IsNumber()
id_evento?: number;
}
@@ -1,6 +1,7 @@
import { CuestionarioSeccion } from 'src/cuestionario_seccion/entities/cuestionario_seccion.entity';
import { Evento } from 'src/evento/evento.entity';
import { TipoCuestionario } from 'src/tipo_cuestionario/entities/tipo_cuestionario.entity';
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, ManyToMany, ManyToOne } from 'typeorm';
import { Entity, PrimaryGeneratedColumn, Column, OneToMany, ManyToMany, ManyToOne, JoinColumn } from 'typeorm';
@@ -39,4 +40,11 @@ export class Cuestionario {
@ManyToOne(()=> Cuestionario)
cuestionarioOriginal: Cuestionario;
@Column({ type: 'int', nullable: true })
id_evento: number;
@ManyToOne(() => Evento)
@JoinColumn({ name: 'id_evento' })
evento: Evento;
}
@@ -1,8 +1,13 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { Controller, Get, Post, Body, Patch, Param, Delete, Res } from '@nestjs/common';
import { CuestionarioRespondidoService } from './cuestionario_respondido.service';
import { CreateCuestionarioRespondidoDto } from './dto/create-cuestionario_respondido.dto';
import { UpdateCuestionarioRespondidoDto } from './dto/update-cuestionario_respondido.dto';
import { SubmitRespuestasDto } from './dto/submit-respuestas.dto';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CuestionarioRespondidoApiDocumentation } from './cuestionario_respondido.documentation';
import { Response } from 'express';
@ApiTags('Cuestionario Respondido')
@Controller('cuestionario-respondido')
export class CuestionarioRespondidoController {
constructor(private readonly cuestionarioRespondidoService: CuestionarioRespondidoService) {}
@@ -12,11 +17,36 @@ export class CuestionarioRespondidoController {
return this.cuestionarioRespondidoService.create(createCuestionarioRespondidoDto);
}
@Post('submit')
@CuestionarioRespondidoApiDocumentation.ApiSubmitRespuestas
@ApiBody({
type: SubmitRespuestasDto,
examples: {
comunidad: CuestionarioRespondidoApiDocumentation.ApiSubmitRespuestasExamples.communityExample,
externos: CuestionarioRespondidoApiDocumentation.ApiSubmitRespuestasExamples.externalExample
}
})
@CuestionarioRespondidoApiDocumentation.ApiSubmitResponse201
@CuestionarioRespondidoApiDocumentation.ApiSubmitResponse400
@CuestionarioRespondidoApiDocumentation.ApiSubmitResponse404
submitRespuestas(@Body() submitRespuestasDto: SubmitRespuestasDto) {
return this.cuestionarioRespondidoService.submitRespuestas(submitRespuestasDto);
}
@Get()
findAll() {
return this.cuestionarioRespondidoService.findAll();
}
@Get('/reporte-respuestas/:id')
@CuestionarioRespondidoApiDocumentation.ApiReporteRespuestas
async descargarReporteRespuestas(@Param('id') id: string, @Res() res: Response) {
const reporte = await this.cuestionarioRespondidoService.generarReporteRespuestas(+id);
res.header('Content-Type', 'text/csv');
res.header('Content-Disposition', `attachment; filename="reporte-cuestionario-${id}.csv"`);
return res.send(reporte);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.cuestionarioRespondidoService.findOne(+id);
@@ -0,0 +1,125 @@
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { applyDecorators } from '@nestjs/common';
export class CuestionarioRespondidoApiDocumentation {
static ApiController = ApiTags('Cuestionario Respondido');
static ApiSubmitRespuestas = ApiOperation({
summary: 'Enviar respuestas a un cuestionario',
description: `Registra las respuestas de un participante a un cuestionario. Crea un registro de participante si no existe uno con ese correo.
## Tipos de respuestas:
- **Preguntas abiertas**: Enviar el texto de la respuesta como string (máximo 250 caracteres)
- **Preguntas cerradas**: Enviar el ID de la opción como número
- **Preguntas de selección múltiple**: Enviar un array de IDs de opciones como [número, número, ...]
### Importante
Para preguntas cerradas y múltiples, se deben enviar los IDs de las opciones, NO los textos.
Esto evita conflictos cuando existen opciones con el mismo texto pero diferentes IDs.
Todas las respuestas de texto y el correo electrónico están limitados a 250 caracteres.`
});
static ApiSubmitResponse201 = ApiResponse({
status: 201,
description: 'Respuestas guardadas correctamente',
schema: {
properties: {
success: { type: 'boolean', example: true },
message: { type: 'string', example: 'Respuestas guardadas correctamente' },
cuestionarioRespondidoId: { type: 'number', example: 1 }
}
}
});
static ApiSubmitResponse400 = ApiResponse({
status: 400,
description: 'Datos inválidos en la solicitud',
schema: {
properties: {
statusCode: { type: 'number', example: 400 },
message: { type: 'string', example: 'La opción con ID 999 no está asociada a la pregunta 101' },
error: { type: 'string', example: 'Bad Request' }
}
}
});
static ApiSubmitResponse404 = ApiResponse({
status: 404,
description: 'Cuestionario o pregunta no encontrada',
schema: {
properties: {
statusCode: { type: 'number', example: 404 },
message: { type: 'string', example: 'Cuestionario con ID 999 no encontrado' },
error: { type: 'string', example: 'Not Found' }
}
}
});
static get ApiReporteRespuestas() {
return applyDecorators(
ApiOperation({
summary: 'Descargar reporte de respuestas de un cuestionario',
description: `
Este endpoint permite descargar un archivo CSV con todas las respuestas de un cuestionario.
El reporte incluye información del participante, evento asociado, y el contenido de las respuestas.
Se generará un archivo CSV con los siguientes campos:
- ID Respuesta
- Fecha Respuesta
- ID Participante
- Correo Participante
- Evento
- Cuestionario
- Pregunta
- Respuesta
`
}),
ApiResponse({
status: 200,
description: 'Reporte de respuestas descargado exitosamente como archivo CSV'
}),
ApiResponse({
status: 404,
description: 'Cuestionario no encontrado'
})
);
}
static ApiSubmitRespuestasExamples = {
communityExample: {
summary: 'Ejemplo de respuesta para un miembro de la comunidad',
value: {
id_formulario: 1,
correo: 'estudiante@acatlan.unam.mx',
respuestas: [
{ id_pregunta: 101, valor: 3 }, // Opción "Si" (usando el ID de la opción)
{ id_pregunta: 102, valor: 'estudiante@acatlan.unam.mx' }, // Correo electrónico
{ id_pregunta: 103, valor: '123456789' }, // Numero de cuenta
{ id_pregunta: 106, valor: 7 } // Opción "Prefiero no decirlo" (usando el ID de la opción)
],
fecha_envio: '2025-04-02T13:45:00'
},
description: 'Datos de un estudiante de la FES Acatlán con respuestas abiertas y opciones por ID.'
},
externalExample: {
summary: 'Ejemplo de respuesta para un participante externo con selección múltiple',
value: {
id_formulario: 1,
correo: 'externo@ejemplo.com',
respuestas: [
{ id_pregunta: 101, valor: 4 }, // Opción "No" (usando el ID de la opción)
{ id_pregunta: 102, valor: 'externo@ejemplo.com' }, // Correo electrónico
{ id_pregunta: 103, valor: 'Juan Carlos' }, // Nombre(s)
{ id_pregunta: 104, valor: 'Ramírez López' }, // Apellidos
{ id_pregunta: 105, valor: [2, 5, 8] }, // Selección múltiple (array de IDs de opciones)
{ id_pregunta: 106, valor: 7 }, // Opción "Prefiero no decirlo" (usando el ID de la opción)
{ id_pregunta: 107, valor: 'UAM Azcapotzalco' } // Institución de procedencia
],
fecha_envio: '2025-04-02T13:45:00'
},
description: 'Datos de un participante externo con respuestas abiertas, opciones por ID y selección múltiple.'
}
};
}
@@ -5,12 +5,30 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { CuestionarioRespondido } from './entities/cuestionario_respondido.entity';
import { CuestionarioModule } from '../cuestionario/cuestionario.module';
import { ParticipanteModule } from '../participante/participante.module';
import { Participante } from '../participante/participante.entity';
import { Cuestionario } from '../cuestionario/entities/cuestionario.entity';
import { Pregunta } from '../pregunta/entities/pregunta.entity';
import { RespuestaParticipanteAbierta } from '../respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
import { RespuestaParticipanteCerrada } from '../respuesta_participante_cerrada/entities/respuesta_participante_cerrada.entity';
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
import { Opcion } from '../opcion/entities/opcion.entity';
import { ValidacionesModule } from '../validaciones/validaciones.module';
@Module({
imports: [
TypeOrmModule.forFeature([CuestionarioRespondido]),
TypeOrmModule.forFeature([
CuestionarioRespondido,
Participante,
Cuestionario,
Pregunta,
RespuestaParticipanteAbierta,
RespuestaParticipanteCerrada,
PreguntaOpcion,
Opcion
]),
CuestionarioModule,
ParticipanteModule
ParticipanteModule,
ValidacionesModule
],
controllers: [CuestionarioRespondidoController],
providers: [CuestionarioRespondidoService],
@@ -1,19 +1,382 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { CreateCuestionarioRespondidoDto } from './dto/create-cuestionario_respondido.dto';
import { UpdateCuestionarioRespondidoDto } from './dto/update-cuestionario_respondido.dto';
import { SubmitRespuestasDto } from './dto/submit-respuestas.dto';
import { CuestionarioRespondido } from './entities/cuestionario_respondido.entity';
import { Participante } from '../participante/participante.entity';
import { Cuestionario } from '../cuestionario/entities/cuestionario.entity';
import { Pregunta } from '../pregunta/entities/pregunta.entity';
import { RespuestaParticipanteAbierta } from '../respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
import { RespuestaParticipanteCerrada } from '../respuesta_participante_cerrada/entities/respuesta_participante_cerrada.entity';
import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity';
import { Opcion } from '../opcion/entities/opcion.entity';
import { ValidadorRespuestasService } from '../validaciones/validador-respuestas.service';
import axios from 'axios';
import * as QRCode from 'qrcode';
@Injectable()
export class CuestionarioRespondidoService {
constructor(
@InjectRepository(CuestionarioRespondido)
private cuestionarioRespondidoRepository: Repository<CuestionarioRespondido>,
@InjectRepository(Participante)
private participanteRepository: Repository<Participante>,
@InjectRepository(Cuestionario)
private cuestionarioRepository: Repository<Cuestionario>,
@InjectRepository(Pregunta)
private preguntaRepository: Repository<Pregunta>,
@InjectRepository(RespuestaParticipanteAbierta)
private respuestaAbiertaRepository: Repository<RespuestaParticipanteAbierta>,
@InjectRepository(RespuestaParticipanteCerrada)
private respuestaCerradaRepository: Repository<RespuestaParticipanteCerrada>,
@InjectRepository(PreguntaOpcion)
private preguntaOpcionRepository: Repository<PreguntaOpcion>,
@InjectRepository(Opcion)
private opcionRepository: Repository<Opcion>,
private dataSource: DataSource,
private validadorRespuestasService: ValidadorRespuestasService,
) {}
create(createCuestionarioRespondidoDto: CreateCuestionarioRespondidoDto) {
return 'This action adds a new cuestionarioRespondido';
}
findAll() {
return `This action returns all cuestionarioRespondido`;
async submitRespuestas(submitDto: SubmitRespuestasDto) {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 1. Verificar que el cuestionario existe
const cuestionario = await this.cuestionarioRepository.findOne({
where: { id_cuestionario: submitDto.id_formulario },
relations: ['evento']
});
if (!cuestionario) {
throw new NotFoundException(`Cuestionario con ID ${submitDto.id_formulario} no encontrado`);
}
// 2. Buscar o crear participante
let participante = await this.participanteRepository.findOne({
where: { correo: submitDto.correo }
});
if (!participante) {
participante = new Participante();
participante.correo = submitDto.correo;
participante.id_tipo_user = 1; // Usar un tipo de usuario por defecto
participante = await queryRunner.manager.save(participante);
}
// 3. Crear un nuevo registro de cuestionario respondido
const cuestionarioRespondido = new CuestionarioRespondido();
cuestionarioRespondido.cuestionario = cuestionario;
cuestionarioRespondido.participante = participante;
cuestionarioRespondido.fechaRespuesta = submitDto.fecha_envio ?
new Date(submitDto.fecha_envio) : new Date();
const savedCuestionarioRespondido = await queryRunner.manager.save(cuestionarioRespondido);
// 4. Procesar cada respuesta
for (const respuestaDto of submitDto.respuestas) {
// Buscar la pregunta
const pregunta = await this.preguntaRepository.findOne({
where: { id_pregunta: respuestaDto.id_pregunta },
relations: ['tipoPregunta']
});
if (!pregunta) {
throw new NotFoundException(`Pregunta con ID ${respuestaDto.id_pregunta} no encontrada`);
}
// Si la pregunta tiene una validación configurada y es una respuesta abierta, validarla
if (pregunta.validacion && typeof respuestaDto.valor === 'string') {
const resultadoValidacion = this.validadorRespuestasService.validarRespuesta(
respuestaDto.valor,
pregunta.validacion
);
if (!resultadoValidacion.valido) {
throw new BadRequestException(`Validación fallida en pregunta ${pregunta.id_pregunta}: ${resultadoValidacion.mensaje}`);
}
}
// Determinar tipo de pregunta por ID
const esPreguntaAbierta = pregunta.id_tipo_pregunta === 2;
// O determinar tipo de pregunta por su nombre si existe
const esPreguntaTipoAbierta = pregunta.tipoPregunta &&
pregunta.tipoPregunta.tipo_pregunta === 'Abierta';
// Determinar tipo de pregunta y guardar respuesta adecuadamente
if (esPreguntaAbierta || esPreguntaTipoAbierta) {
// Respuesta abierta (texto)
// Convertir a string y truncar a 250 caracteres si es necesario
let respuestaTexto = String(respuestaDto.valor);
if (respuestaTexto.length > 250) {
respuestaTexto = respuestaTexto.substring(0, 250);
}
const respuestaAbierta = new RespuestaParticipanteAbierta();
respuestaAbierta.pregunta = pregunta;
respuestaAbierta.respuesta = respuestaTexto;
respuestaAbierta.cuestionarioRespondido = savedCuestionarioRespondido;
await queryRunner.manager.save(respuestaAbierta);
} else {
// Respuesta cerrada (opción)
// Verificar si es una respuesta múltiple (array de IDs)
const opcionIds = Array.isArray(respuestaDto.valor)
? respuestaDto.valor
: [respuestaDto.valor];
// Procesar cada ID de opción
for (const opcionId of opcionIds) {
// Buscar la relación entre pregunta y opción usando el ID de opción
const preguntaOpciones = await this.preguntaOpcionRepository
.createQueryBuilder('po')
.innerJoinAndSelect('po.opcion', 'opcion')
.where('po.id_pregunta = :preguntaId', { preguntaId: pregunta.id_pregunta })
.andWhere('opcion.id_opcion = :opcionId', { opcionId: opcionId })
.getMany();
if (preguntaOpciones.length === 0) {
throw new BadRequestException(`La opción con ID ${opcionId} no está asociada a la pregunta ${respuestaDto.id_pregunta}`);
}
// Guardar respuesta cerrada
const respuestaCerrada = new RespuestaParticipanteCerrada();
respuestaCerrada.preguntaOpcion = preguntaOpciones[0];
respuestaCerrada.cuestionarioRespondido = savedCuestionarioRespondido;
await queryRunner.manager.save(respuestaCerrada);
}
}
}
// Variables para el QR y correo electrónico
let datosQR: {
id_participante: number;
id_evento: number;
} | null = null;
// Capturamos los datos para el QR
if (cuestionario.evento && cuestionario.evento.id_evento) {
datosQR = {
id_participante: participante.id_participante,
id_evento: cuestionario.evento.id_evento
};
}
// Primero completamos la transacción principal
await queryRunner.commitTransaction();
// Después de la transacción principal, intentamos registrar la relación participante-evento
// y enviar el correo como operaciones independientes
if (cuestionario.evento && cuestionario.evento.id_evento) {
try {
// Registrar participante en evento
try {
// Verificar si ya existe una relación participante-evento
const participanteEventoExistente = await this.dataSource.query(
`SELECT * FROM participante_evento WHERE id_participante = ? AND id_evento = ?`,
[participante.id_participante, cuestionario.evento.id_evento]
);
// Si no existe, crear la relación
if (!participanteEventoExistente || participanteEventoExistente.length === 0) {
await this.dataSource.query(
`INSERT INTO participante_evento (id_participante, id_evento, fecha_inscripcion, estatus) VALUES (?, ?, ?, ?)`,
[participante.id_participante, cuestionario.evento.id_evento, new Date(), true]
);
}
} catch (dbError) {
console.error('Error al registrar participante en evento:', dbError.message);
}
// Generar código QR
const qrJsonData = JSON.stringify(datosQR);
const qrBuffer = await QRCode.toBuffer(qrJsonData);
const qrDataUrl = await QRCode.toDataURL(qrJsonData);
console.log('QR Data URL:', qrDataUrl);
console.log("antes de enviar correo");
// Enviar correo con el QR
const urlApiCorreos = process.env.url_api_correos || 'http://localhost:3000';
await axios.post(`${urlApiCorreos}/mail/send`, {
to: participante.correo,
subject: 'Asistencia QR',
fecha_recibido: "2025-03-10T12:00:00Z",
html: `
<h1>¡Gracias por registrarte!</h1>
<p>Has completado exitosamente el formulario: ${cuestionario.nombre_form}</p>
<p>Este es tu QR para registrar asistencia:</p>
<img src="cid:qrCode" alt="Código QR" />
<p>Tus datos de registro:</p>
<ul>
<li>Evento: ${cuestionario.evento.nombre_evento || 'Evento'}</li>
<li>Correo: ${participante.correo}</li>
<li>Fecha de registro: ${new Date().toLocaleString()}</li>
</ul>
`,
adjuntos: [
{
filename: 'qr.png',
content: qrBuffer.toString('base64'),
encoding: 'base64',
cid: 'qrCode'
}
]
});
console.log(`Correo enviado exitosamente a ${participante.correo}`);
} catch (error) {
console.error('Error después de la transacción principal:', error);
// No lanzamos el error para que no afecte la respuesta al usuario
}
}
// Preparar respuesta
const response: any = {
success: true,
message: 'Respuestas guardadas correctamente',
cuestionarioRespondidoId: savedCuestionarioRespondido.idCuestionarioRespondido
};
// Incluir IDs para generar QR si hay evento asociado
if (datosQR) {
response.datos_qr = datosQR;
}
return response;
} catch (error) {
// Solo hacemos rollback si no hemos hecho commit todavía
try {
await queryRunner.rollbackTransaction();
} catch (rollbackError) {
console.error('Error durante rollback:', rollbackError.message);
}
throw error;
} finally {
await queryRunner.release();
}
}
findOne(id: number) {
return `This action returns a #${id} cuestionarioRespondido`;
async findAll() {
// Obtener todos los cuestionarios respondidos con sus relaciones básicas
const cuestionariosRespondidos = await this.cuestionarioRespondidoRepository.find({
relations: ['participante', 'cuestionario', 'respuestasAbiertas', 'respuestasAbiertas.pregunta', 'respuestasCerradas']
});
// Para cada cuestionario respondido, obtener y formatear las respuestas cerradas
const resultados = await Promise.all(
cuestionariosRespondidos.map(async (cuestionarioRespondido) => {
// Obtener datos de respuestas cerradas con consulta SQL
const respuestasCerradas = await this.dataSource.query(`
SELECT
rpc.idRespuestaParticipanteCerrada,
rpc.id_pregunta_opcion,
po.id_pregunta,
p.pregunta,
p.id_tipo_pregunta,
o.id_opcion,
o.opcion
FROM respuesta_participante_cerrada rpc
LEFT JOIN pregunta_opcion po ON rpc.id_pregunta_opcion = po.id_pregunta_opcion
LEFT JOIN pregunta p ON po.id_pregunta = p.id_pregunta
LEFT JOIN opcion o ON po.id_opcion = o.id_opcion
WHERE rpc.id_cuestionario_respondido = ?
`, [cuestionarioRespondido.idCuestionarioRespondido]);
// Formatear respuestas cerradas
const respuestasCerradasFormateadas = respuestasCerradas.map(respuesta => ({
idRespuestaParticipanteCerrada: respuesta.idRespuestaParticipanteCerrada,
id_pregunta_opcion: respuesta.id_pregunta_opcion,
pregunta: {
id_pregunta: respuesta.id_pregunta,
texto_pregunta: respuesta.pregunta,
id_tipo_pregunta: respuesta.id_tipo_pregunta
},
opcion: {
id_opcion: respuesta.id_opcion,
opcion: respuesta.opcion
}
}));
// Devolver cuestionario con respuestas formateadas
return {
...cuestionarioRespondido,
respuestasCerradas: respuestasCerradasFormateadas
};
})
);
return resultados;
}
async findOne(id: number) {
// Obtener cuestionario respondido con sus relaciones básicas
const cuestionarioRespondido = await this.cuestionarioRespondidoRepository.findOne({
where: { idCuestionarioRespondido: id },
relations: [
'participante',
'cuestionario',
'respuestasAbiertas',
'respuestasAbiertas.pregunta',
'respuestasCerradas'
]
});
if (!cuestionarioRespondido) {
throw new NotFoundException(`Cuestionario respondido con ID ${id} no encontrado`);
}
// Obtener directamente los datos de respuestas cerradas desde la base de datos
// usando una consulta SQL directa
const respuestasCerradas = await this.dataSource.query(`
SELECT
rpc.idRespuestaParticipanteCerrada,
rpc.id_pregunta_opcion,
po.id_pregunta,
p.pregunta,
p.id_tipo_pregunta,
o.id_opcion,
o.opcion
FROM respuesta_participante_cerrada rpc
LEFT JOIN pregunta_opcion po ON rpc.id_pregunta_opcion = po.id_pregunta_opcion
LEFT JOIN pregunta p ON po.id_pregunta = p.id_pregunta
LEFT JOIN opcion o ON po.id_opcion = o.id_opcion
WHERE rpc.id_cuestionario_respondido = ?
`, [id]);
console.log('Respuestas cerradas SQL:', JSON.stringify(respuestasCerradas, null, 2));
// Transformar las respuestas cerradas al formato deseado
const respuestasCerradasFormateadas = respuestasCerradas.map(respuesta => ({
idRespuestaParticipanteCerrada: respuesta.idRespuestaParticipanteCerrada,
id_pregunta_opcion: respuesta.id_pregunta_opcion,
pregunta: {
id_pregunta: respuesta.id_pregunta,
texto_pregunta: respuesta.pregunta,
id_tipo_pregunta: respuesta.id_tipo_pregunta
},
opcion: {
id_opcion: respuesta.id_opcion,
opcion: respuesta.opcion
}
}));
// Crear el objeto de respuesta con el formato deseado
const resultado = {
...cuestionarioRespondido,
respuestasCerradas: respuestasCerradasFormateadas
};
return resultado;
}
update(id: number, updateCuestionarioRespondidoDto: UpdateCuestionarioRespondidoDto) {
@@ -23,4 +386,98 @@ export class CuestionarioRespondidoService {
remove(id: number) {
return `This action removes a #${id} cuestionarioRespondido`;
}
async generarReporteRespuestas(idCuestionario: number): Promise<string> {
// Buscar el cuestionario para validar que existe
const cuestionario = await this.cuestionarioRepository.findOne({
where: { id_cuestionario: idCuestionario },
relations: ['evento'],
});
if (!cuestionario) {
throw new NotFoundException(`Cuestionario con ID ${idCuestionario} no encontrado`);
}
// Obtener todas las respuestas para este cuestionario con sus relaciones
const respuestas = await this.dataSource.query(`
SELECT
cr.id_cuestionario_respondido,
cr.fecha_respuesta,
p.id_participante,
p.correo AS correo_participante,
c.id_cuestionario,
c.nombre_form AS nombre_cuestionario,
e.id_evento,
e.nombre_evento,
preg.id_pregunta,
preg.pregunta,
-- Para respuestas abiertas
rpa.respuesta AS respuesta_abierta,
-- Para respuestas cerradas
o.id_opcion,
o.opcion AS respuesta_cerrada
FROM cuestionario_respondido cr
JOIN participante p ON cr.id_participante = p.id_participante
JOIN cuestionario c ON cr.id_cuestionario = c.id_cuestionario
LEFT JOIN evento e ON c.id_evento = e.id_evento
-- Respuestas abiertas
LEFT JOIN respuesta_participante_abierta rpa ON rpa.id_cuestionario_respondido = cr.id_cuestionario_respondido
LEFT JOIN pregunta preg_abierta ON rpa.id_pregunta = preg_abierta.id_pregunta
-- Respuestas cerradas
LEFT JOIN respuesta_participante_cerrada rpc ON rpc.id_cuestionario_respondido = cr.id_cuestionario_respondido
LEFT JOIN pregunta_opcion po ON rpc.id_pregunta_opcion = po.id_pregunta_opcion
LEFT JOIN pregunta preg ON po.id_pregunta = preg.id_pregunta
LEFT JOIN opcion o ON po.id_opcion = o.id_opcion
WHERE c.id_cuestionario = ?
ORDER BY cr.id_cuestionario_respondido, preg.id_pregunta
`, [idCuestionario]);
// Preparar los datos para el CSV
const datosReporte: string[][] = [];
// Agregar encabezados
const encabezados: string[] = [
'ID Respuesta',
'Fecha Respuesta',
'ID Participante',
'Correo Participante',
'Evento',
'Cuestionario',
'Pregunta',
'Respuesta'
];
datosReporte.push(encabezados);
// Procesar los resultados para el CSV
for (const fila of respuestas) {
const respuesta = fila.respuesta_abierta || fila.respuesta_cerrada || '';
datosReporte.push([
fila.id_cuestionario_respondido?.toString() || '',
new Date(fila.fecha_respuesta).toLocaleString(),
fila.id_participante?.toString() || '',
fila.correo_participante || '',
fila.nombre_evento || 'Sin evento',
fila.nombre_cuestionario || '',
fila.pregunta || '',
respuesta
]);
}
// Generar CSV de forma manual con valores separados por comas y líneas con saltos de línea
// Escapar valores que contengan comas para evitar problemas con el formato CSV
const csvContent = datosReporte.map(row =>
row.map(value => {
// Si el valor contiene comas, comillas o saltos de línea, encerrarlo en comillas dobles
// y escapar cualquier comilla doble dentro del valor
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}).join(',')
).join('\n');
return csvContent;
}
}
@@ -0,0 +1,92 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsArray,
IsDateString,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
ValidateNested,
ValidateIf,
MaxLength
} from 'class-validator';
import { Type, Transform } from 'class-transformer';
export class RespuestaDto {
@ApiProperty({
description: 'ID de la pregunta respondida',
example: 101
})
@IsNumber()
id_pregunta: number;
@ApiProperty({
description: 'Valor de la respuesta según tipo de pregunta',
examples: [
'Texto para pregunta abierta', // String para preguntas abiertas
5, // Número para preguntas cerradas (ID de opción)
[2, 7, 9] // Array para preguntas múltiples (IDs de opciones)
],
oneOf: [
{ type: 'string', description: 'Texto para pregunta abierta (máximo 250 caracteres)' },
{ type: 'number', description: 'ID de opción para pregunta cerrada' },
{
type: 'array',
items: { type: 'number' },
description: 'Array de IDs de opciones para pregunta multiple'
}
]
})
@ValidateIf(o => typeof o.valor === 'string')
@MaxLength(250, { message: 'Las respuestas de texto no pueden exceder 250 caracteres' })
valor: string | number | number[];
}
export class SubmitRespuestasDto {
@ApiProperty({
description: 'ID del formulario a responder',
example: 1
})
@IsNumber()
id_formulario: number;
@ApiProperty({
description: 'Correo electrónico del participante',
example: 'usuario@ejemplo.com'
})
@IsString()
@IsNotEmpty()
@MaxLength(250, { message: 'El correo electrónico no puede exceder 250 caracteres' })
correo: string;
@ApiProperty({
description: 'Array de respuestas a las preguntas',
type: [RespuestaDto],
examples: [
{
id_pregunta: 101,
valor: 'Texto para pregunta abierta'
},
{
id_pregunta: 102,
valor: 5 // ID de la opción para pregunta cerrada simple
},
{
id_pregunta: 103,
valor: [2, 7, 9] // Array de IDs de opciones para pregunta de selección múltiple
}
]
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => RespuestaDto)
respuestas: RespuestaDto[];
@ApiProperty({
description: 'Fecha y hora del envío',
example: '2025-04-02T13:45:00'
})
@IsDateString()
@IsOptional()
fecha_envio?: string;
}
@@ -4,6 +4,7 @@ import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity';
import { RespuestaParticipanteCerrada } from 'src/respuesta_participante_cerrada/entities/respuesta_participante_cerrada.entity';
import { RespuestaParticipanteAbierta } from 'src/respuesta_participante_abierta/entities/respuesta_participante_abierta.entity';
import { Participante } from 'src/participante/participante.entity';
import { Opcion } from 'src/opcion/entities/opcion.entity';
@Entity('cuestionario_respondido')
export class CuestionarioRespondido {
+4
View File
@@ -18,6 +18,10 @@ export const swaggerConfig = new DocumentBuilder()
La API permite crear cuestionarios complejos con secciones y diferentes tipos de preguntas.
Consulte la documentación de cada endpoint para ver ejemplos específicos.
`)
.addServer(process.env.SWAGGER_SERVER_URL || 'http://localhost:3000')
.addServer( 'http://localhost:3000')
.setVersion('1.0')
.addTag('Cuestionarios')
.addTag('Secciones')
@@ -1,7 +1,22 @@
import { IsEmail } from "class-validator";
import { IsEmail, IsNotEmpty, IsNumber } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class CreateParticipanteDto {
@IsEmail()
correo: string
id_tipo_user: number
@ApiProperty({
description: 'Correo electrónico del participante',
example: 'usuario@ejemplo.com',
required: true
})
@IsEmail({}, { message: 'El correo electrónico debe tener un formato válido' })
@IsNotEmpty({ message: 'El correo electrónico es requerido' })
correo: string;
@ApiProperty({
description: 'ID del tipo de usuario',
example: 1,
required: true
})
@IsNumber({}, { message: 'El ID del tipo de usuario debe ser un número' })
@IsNotEmpty({ message: 'El ID del tipo de usuario es requerido' })
id_tipo_user: number;
}
@@ -1,3 +1,22 @@
import { IsEmail, IsOptional, IsNumber } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
export class UpdateParticipanteDto {
correo: string
@ApiProperty({
description: 'Nuevo correo electrónico del participante',
example: 'nuevo_correo@ejemplo.com',
required: false
})
@IsEmail({}, { message: 'El correo electrónico debe tener un formato válido' })
@IsOptional()
correo?: string;
@ApiProperty({
description: 'Nuevo ID del tipo de usuario',
example: 2,
required: false
})
@IsNumber({}, { message: 'El ID del tipo de usuario debe ser un número' })
@IsOptional()
id_tipo_user?: number;
}
+10 -28
View File
@@ -3,58 +3,40 @@ import { Participante } from './participante.entity';
import { ParticipanteService } from './participante.service';
import { CreateParticipanteDto } from './dto/create-participante.dto';
import { UpdateParticipanteDto } from './dto/update.participante.dto';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger';
import { ParticipanteApiDocumentation } from './participante.documentation';
@ApiTags('Participantes') // Agrupa los endpoints en Swagger
@ParticipanteApiDocumentation.ApiController
@Controller('participante')
export class ParticipanteController {
constructor(private participanteService: ParticipanteService) {}
@ApiOperation({ summary: 'Obtener todos los participantes' })
@ApiResponse({ status: 200, description: 'Lista de participantes obtenida correctamente.' })
@ParticipanteApiDocumentation.ApiGetAll
@Get()
getParticipantes(): Promise<Participante[]> {
return this.participanteService.getParticipantes()
return this.participanteService.getParticipantes();
}
@ParticipanteApiDocumentation.ApiGetOne
@Get(':id')
@ApiOperation({ summary: 'Obtener un participante por ID' })
@ApiParam({ name: 'id', description: 'ID del participante', example: 1 })
@ApiResponse({ status: 200, description: 'Participante obtenido correctamente.' })
@ApiResponse({ status: 404, description: 'Participante no encontrado.' })
getParticipante(@Param('id', ParseIntPipe) id: number) {
return this.participanteService.getParticipante(id);
}
@ParticipanteApiDocumentation.ApiCreate
@Post()
@ApiOperation({ summary: 'Registrar un nuevo participante' })
@ApiBody({
description: 'Datos del participante a registrar',
schema: {
type: 'object',
properties: {
correo: { type: 'string', example: 'user@example.com' },
id_tipo_user: { type: 'integer', example: 2 }
}
}
})
@ApiResponse({ status: 201, description: 'Participante registrado exitosamente.' })
@ApiResponse({ status: 400, description: 'Datos inválidos.' })
createParticipante(@Body() newParticipante: CreateParticipanteDto) {
return this.participanteService.createParticipante(newParticipante);
}
@ParticipanteApiDocumentation.ApiRemove
@Delete(':id')
deleteParticipante(@Param('id', ParseIntPipe) id: number) {
return this.participanteService.deleteParticipante(id)
return this.participanteService.deleteParticipante(id);
}
@ParticipanteApiDocumentation.ApiUpdate
@Patch(':id')
updateParticipante(@Param('correo') id: number, @Body() participante: UpdateParticipanteDto) {
updateParticipante(@Param('id', ParseIntPipe) id: number, @Body() participante: UpdateParticipanteDto) {
return this.participanteService.updateParticipante(id, participante);
}
}
@@ -0,0 +1,211 @@
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { applyDecorators } from '@nestjs/common';
export class ParticipanteApiDocumentation {
// Decorador para toda la clase del controlador
static ApiController = ApiTags('Participantes');
// Documentación para crear un participante
static ApiCreate = applyDecorators(
ApiOperation({
summary: 'Registrar un nuevo participante',
description: 'Crea un nuevo registro de participante en el sistema'
}),
ApiBody({
description: 'Datos del participante a registrar',
schema: {
type: 'object',
required: ['correo', 'id_tipo_user'],
properties: {
correo: {
type: 'string',
format: 'email',
example: 'usuario@ejemplo.com',
description: 'Correo electrónico del participante'
},
id_tipo_user: {
type: 'integer',
example: 1,
description: 'ID del tipo de usuario'
}
}
}
}),
ApiResponse({
status: 201,
description: 'Participante registrado exitosamente',
schema: {
type: 'object',
properties: {
id_participante: { type: 'number', example: 1 },
correo: { type: 'string', example: 'usuario@ejemplo.com' },
id_tipo_user: { type: 'number', example: 1 }
}
}
}),
ApiResponse({ status: 400, description: 'Datos del participante inválidos' }),
ApiResponse({ status: 409, description: 'El participante ya existe' })
);
// Documentación para obtener todos los participantes
static ApiGetAll = applyDecorators(
ApiOperation({
summary: 'Obtener todos los participantes',
description: 'Retorna una lista de todos los participantes registrados'
}),
ApiResponse({
status: 200,
description: 'Lista de participantes obtenida correctamente',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante: { type: 'number', example: 1 },
correo: { type: 'string', example: 'usuario@ejemplo.com' },
id_tipo_user: { type: 'number', example: 1 },
tipo_user: {
type: 'object',
properties: {
id_tipo_user: { type: 'number', example: 1 },
tipo_user: { type: 'string', example: 'Estudiante' }
}
},
participanteEventos: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante_evento: { type: 'number', example: 1 },
id_participante: { type: 'number', example: 1 },
id_evento: { type: 'number', example: 1 },
fecha_inscripcion: { type: 'string', format: 'date-time', example: '2025-04-01T10:00:00Z' },
estatus: { type: 'boolean', example: true }
}
}
}
}
}
}
})
);
// Documentación para obtener un participante por ID
static ApiGetOne = applyDecorators(
ApiOperation({
summary: 'Obtener un participante por ID',
description: 'Retorna los datos de un participante específico según su ID'
}),
ApiParam({
name: 'id',
description: 'ID del participante',
type: 'number',
example: 1
}),
ApiResponse({
status: 200,
description: 'Participante obtenido correctamente',
schema: {
type: 'object',
properties: {
id_participante: { type: 'number', example: 1 },
correo: { type: 'string', example: 'usuario@ejemplo.com' },
id_tipo_user: { type: 'number', example: 1 },
tipo_user: {
type: 'object',
properties: {
id_tipo_user: { type: 'number', example: 1 },
tipo_user: { type: 'string', example: 'Estudiante' }
}
},
participanteEventos: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante_evento: { type: 'number', example: 1 },
id_participante: { type: 'number', example: 1 },
id_evento: { type: 'number', example: 1 },
fecha_inscripcion: { type: 'string', format: 'date-time', example: '2025-04-01T10:00:00Z' },
estatus: { type: 'boolean', example: true }
}
}
}
}
}
}),
ApiResponse({ status: 404, description: 'Participante no encontrado' })
);
// Documentación para actualizar un participante
static ApiUpdate = applyDecorators(
ApiOperation({
summary: 'Actualizar datos de un participante',
description: 'Actualiza la información de un participante existente'
}),
ApiParam({
name: 'id',
description: 'ID del participante a actualizar',
type: 'number',
example: 1
}),
ApiBody({
description: 'Datos a actualizar del participante',
schema: {
type: 'object',
properties: {
correo: {
type: 'string',
format: 'email',
example: 'nuevo_correo@ejemplo.com',
description: 'Nuevo correo electrónico del participante'
},
id_tipo_user: {
type: 'integer',
example: 2,
description: 'Nuevo tipo de usuario'
}
}
}
}),
ApiResponse({
status: 200,
description: 'Participante actualizado correctamente',
schema: {
type: 'object',
properties: {
id_participante: { type: 'number', example: 1 },
correo: { type: 'string', example: 'nuevo_correo@ejemplo.com' },
id_tipo_user: { type: 'number', example: 2 }
}
}
}),
ApiResponse({ status: 400, description: 'Datos de actualización inválidos' }),
ApiResponse({ status: 404, description: 'Participante no encontrado' })
);
// Documentación para eliminar un participante
static ApiRemove = applyDecorators(
ApiOperation({
summary: 'Eliminar un participante',
description: 'Elimina permanentemente un participante por su ID'
}),
ApiParam({
name: 'id',
description: 'ID del participante a eliminar',
type: 'number',
example: 1
}),
ApiResponse({
status: 200,
description: 'Participante eliminado correctamente',
schema: {
type: 'object',
properties: {
affected: { type: 'number', example: 1 }
}
}
}),
ApiResponse({ status: 404, description: 'Participante no encontrado' })
);
}
+72 -22
View File
@@ -1,62 +1,99 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Participante } from './participante.entity';
import { Repository } from 'typeorm';
import { CreateParticipanteDto } from './dto/create-participante.dto';
//import { UpdateAdminDto } from 'src/admin/dto/update.admin.dto';
import { UpdateParticipanteDto } from './dto/update.participante.dto';
@Injectable()
export class ParticipanteService {
constructor(
@InjectRepository(Participante) private participanteRepository: Repository<Participante>
) {}
async createParticipante(participante: CreateParticipanteDto) {
/**
* Crea un nuevo participante
* @param participante Datos del participante a crear
* @returns El participante creado
* @throws ConflictException si ya existe un participante con el mismo correo
*/
async createParticipante(participante: CreateParticipanteDto): Promise<Participante> {
// Verificar si ya existe un participante con el mismo correo
const participanteFound = await this.participanteRepository.findOne({
where: {
correo: participante.correo
}
})
});
if (participanteFound)
return new HttpException('Participante already exists', HttpStatus.CONFLICT)
if (participanteFound) {
throw new ConflictException(`Ya existe un participante con el correo ${participante.correo}`);
}
return this.participanteRepository.save(participante)
const nuevoParticipante = this.participanteRepository.create(participante);
return this.participanteRepository.save(nuevoParticipante);
}
getParticipantes() {
/**
* Obtiene todos los participantes
* @returns Lista de participantes con sus relaciones
*/
async getParticipantes(): Promise<Participante[]> {
return this.participanteRepository.find({
relations: ['tipo_user', 'participanteEventos']
})
});
}
async getParticipante(id_participante: number) {
/**
* Obtiene un participante por su ID
* @param id_participante ID del participante a buscar
* @returns El participante encontrado con sus relaciones
* @throws NotFoundException si no se encuentra el participante
*/
async getParticipante(id_participante: number): Promise<Participante> {
const participanteFound = await this.participanteRepository.findOne({
where: {
id_participante
},
relations: ['tipo_user', 'participanteEventos']
})
});
if (!participanteFound)
return new HttpException('Participante not found', HttpStatus.NOT_FOUND);
if (!participanteFound) {
throw new NotFoundException(`Participante con ID ${id_participante} no encontrado`);
}
return participanteFound;
}
/**
* Elimina un participante por su ID
* @param id_participante ID del participante a eliminar
* @returns Resultado de la eliminación
* @throws NotFoundException si no se encuentra el participante
*/
async deleteParticipante(id_participante: number) {
const result = await this.participanteRepository.delete({ id_participante })
const result = await this.participanteRepository.delete({ id_participante });
if (result.affected === 0) {
return new HttpException('Participante not found', HttpStatus.NOT_FOUND);
throw new NotFoundException(`Participante con ID ${id_participante} no encontrado`);
}
return result;
return {
success: true,
message: `Participante con ID ${id_participante} eliminado correctamente`,
affected: result.affected
};
}
async updateParticipante(id_participante: number, participante: UpdateParticipanteDto) {
/**
* Actualiza los datos de un participante
* @param id_participante ID del participante a actualizar
* @param participante Datos actualizados del participante
* @returns El participante actualizado
* @throws NotFoundException si no se encuentra el participante
* @throws BadRequestException si los datos son inválidos
*/
async updateParticipante(id_participante: number, participante: UpdateParticipanteDto): Promise<Participante> {
// Verificar si el participante existe
const participanteFound = await this.participanteRepository.findOne({
where: {
id_participante
@@ -64,11 +101,24 @@ export class ParticipanteService {
});
if (!participanteFound) {
return new HttpException('Participante not found', HttpStatus.NOT_FOUND)
throw new NotFoundException(`Participante con ID ${id_participante} no encontrado`);
}
const updateParticipante = Object.assign(participanteFound, participante)
return this.participanteRepository.save(updateParticipante)
}
// Si se está actualizando el correo, verificar que no exista otro participante con ese correo
if (participante.correo && participante.correo !== participanteFound.correo) {
const existingParticipante = await this.participanteRepository.findOne({
where: {
correo: participante.correo
}
});
if (existingParticipante && existingParticipante.id_participante !== id_participante) {
throw new ConflictException(`Ya existe otro participante con el correo ${participante.correo}`);
}
}
// Actualizar el participante
const updateParticipante = Object.assign(participanteFound, participante);
return this.participanteRepository.save(updateParticipante);
}
}
@@ -3,32 +3,182 @@ import { ParticipanteEventoService } from './participante_evento.service';
import { ParticipanteEvento } from './participante_evento.entity';
import { CreateParticipanteEventoDto } from './dto/create-participante_evento.dto';
import { UpdateParticipanteEventoDto } from './dto/update.participante_evento.dto';
import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
@ApiTags('Participantes-Eventos')
@Controller('participante-evento')
export class ParticipanteEventoController {
constructor(private participanteEventoService: ParticipanteEventoService) {}
@Get()
getParticipantesEvento(): Promise<ParticipanteEvento[]> {
return this.participanteEventoService.getParticipantesEvento()
@ApiOperation({ summary: 'Obtener participantes por evento' })
@ApiParam({ name: 'idEvento', description: 'ID del evento', type: 'number' })
@ApiResponse({
status: 200,
description: 'Lista de participantes que están registrados en el evento',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante_evento: { type: 'number' },
id_participante: { type: 'number' },
id_evento: { type: 'number' },
fecha_inscripcion: { type: 'string', format: 'date-time' },
estatus: { type: 'boolean' },
fecha_asistencia: { type: 'string', format: 'date-time', nullable: true },
participante: {
type: 'object',
properties: {
id_participante: { type: 'number' },
correo: { type: 'string' },
id_tipo_user: { type: 'number' }
}
}
}
}
}
})
@ApiResponse({ status: 404, description: 'Evento no encontrado' })
@Get('evento/:idEvento')
getParticipantesPorEvento(@Param('idEvento', ParseIntPipe) idEvento: number) {
return this.participanteEventoService.getParticipantesPorEvento(idEvento);
}
@ApiOperation({ summary: 'Obtener eventos por participante' })
@ApiParam({ name: 'idParticipante', description: 'ID del participante', type: 'number' })
@ApiResponse({
status: 200,
description: 'Lista de eventos en los que está registrado el participante',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante_evento: { type: 'number' },
id_participante: { type: 'number' },
id_evento: { type: 'number' },
fecha_inscripcion: { type: 'string', format: 'date-time' },
estatus: { type: 'boolean' },
fecha_asistencia: { type: 'string', format: 'date-time', nullable: true },
evento: {
type: 'object',
properties: {
id_evento: { type: 'number' },
nombre_evento: { type: 'string' },
tipo_evento: { type: 'string' },
fecha_inicio: { type: 'string', format: 'date-time' },
fecha_fin: { type: 'string', format: 'date-time' }
}
}
}
}
}
})
@ApiResponse({ status: 404, description: 'Participante no encontrado' })
@Get('participante/:idParticipante')
getEventosPorParticipante(@Param('idParticipante', ParseIntPipe) idParticipante: number) {
return this.participanteEventoService.getEventosPorParticipante(idParticipante);
}
@ApiOperation({ summary: 'Obtener un registro específico por IDs de participante y evento' })
@ApiParam({ name: 'idParticipante', description: 'ID del participante', type: 'number' })
@ApiParam({ name: 'idEvento', description: 'ID del evento', type: 'number' })
@ApiResponse({
status: 200,
description: 'Registro participante-evento obtenido correctamente'
})
@ApiResponse({ status: 404, description: 'Registro no encontrado' })
@Get(':idParticipante/:idEvento')
getParticipanteEventoEspecifico(
@Param('idParticipante', ParseIntPipe) idParticipante: number,
@Param('idEvento', ParseIntPipe) idEvento: number
) {
return this.participanteEventoService.getParticipanteEventoEspecifico(idParticipante, idEvento)
}
@ApiOperation({ summary: 'Obtener un registro participante-evento por ID' })
@ApiParam({ name: 'id', description: 'ID del participante', type: 'number' })
@ApiResponse({
status: 200,
description: 'Registro participante-evento obtenido correctamente'
})
@ApiResponse({ status: 404, description: 'Registro no encontrado' })
@Get(':id')
getParticipanteEvento(@Param('id', ParseIntPipe) id: number) {
return this.participanteEventoService.getParticipanteEvento(id)
}
@ApiOperation({ summary: 'Obtener todos los registros de participante-evento' })
@ApiResponse({
status: 200,
description: 'Lista de todos los registros participante-evento con sus relaciones',
schema: {
type: 'array',
items: {
type: 'object',
properties: {
id_participante_evento: { type: 'number' },
id_participante: { type: 'number' },
id_evento: { type: 'number' },
fecha_inscripcion: { type: 'string', format: 'date-time' },
estatus: { type: 'boolean' },
fecha_asistencia: { type: 'string', format: 'date-time', nullable: true }
}
}
}
})
@Get()
getParticipantesEvento(): Promise<ParticipanteEvento[]> {
return this.participanteEventoService.getParticipantesEvento()
}
@ApiOperation({ summary: 'Registrar un participante en un evento' })
@ApiResponse({
status: 201,
description: 'Participante registrado en el evento correctamente'
})
@ApiResponse({ status: 409, description: 'El participante ya está registrado en este evento' })
@Post()
createParticipanteEvento(@Body() newParticipanteEvento: CreateParticipanteEventoDto) {
return this.participanteEventoService.createParticipanteEvento(newParticipanteEvento)
}
@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')
registrarAsistencia(
@Param('idParticipante', ParseIntPipe) idParticipante: number,
@Param('idEvento', ParseIntPipe) idEvento: number
) {
return this.participanteEventoService.registrarAsistencia(idParticipante, idEvento)
}
@ApiOperation({ summary: 'Eliminar un registro participante-evento' })
@ApiParam({ name: 'id', description: 'ID del participante', type: 'number' })
@ApiResponse({
status: 200,
description: 'Registro eliminado correctamente'
})
@ApiResponse({ status: 404, description: 'Registro no encontrado' })
@Delete(':id')
deleteParticipanteEvento(@Param('id', ParseIntPipe) id: number) {
return this.participanteEventoService.deleteParticipanteEvento(id)
}
@ApiOperation({ summary: 'Actualizar un registro participante-evento' })
@ApiParam({ name: 'id', description: 'ID del participante', type: 'number' })
@ApiResponse({
status: 200,
description: 'Registro actualizado correctamente'
})
@ApiResponse({ status: 404, description: 'Registro no encontrado' })
@Patch(':id')
updateParticipanteEvento(@Param('id', ParseIntPipe) id_participante: number, id_evento: number, @Body() participanteEvento: UpdateParticipanteEventoDto) {
return this.participanteEventoService.updateParticipanteEvento(id_participante, id_evento, participanteEvento)
@@ -15,6 +15,9 @@ export class ParticipanteEvento {
@Column()
estatus: boolean
@Column({ nullable: true, type: 'datetime' })
fecha_asistencia: Date
/*
@OneToOne(() => Qr, (qr) => qr.participanteEvento)
qr: Qr;
@@ -53,6 +53,70 @@ export class ParticipanteEventoService {
return participante_eventoFound
}
async getParticipanteEventoEspecifico(id_participante: number, id_evento: number) {
const participante_eventoFound = await this.participanteEventoRepository.findOne({
where: {
id_participante,
id_evento
},
relations: ['evento', 'participante']
})
if (!participante_eventoFound) {
return new HttpException('Registro no encontrado', HttpStatus.NOT_FOUND);
}
return participante_eventoFound
}
/**
* Obtiene todos los participantes de un evento específico
* @param id_evento ID del evento
* @returns Lista de registros participante-evento con información del participante
*/
async getParticipantesPorEvento(id_evento: number) {
// Verificar si el evento existe
const eventoExists = await this.eventoRepository.findOne({
where: { id_evento }
});
if (!eventoExists) {
throw new HttpException(`Evento con ID ${id_evento} no encontrado`, HttpStatus.NOT_FOUND);
}
// Obtener todos los registros participante-evento para este evento
const participantesEvento = await this.participanteEventoRepository.find({
where: { id_evento },
relations: ['participante']
});
return participantesEvento;
}
/**
* Obtiene todos los eventos de un participante específico
* @param id_participante ID del participante
* @returns Lista de registros participante-evento con información del evento
*/
async getEventosPorParticipante(id_participante: number) {
// Verificar si el participante existe
const participanteExists = await this.participanteRepository.findOne({
where: { id_participante }
});
if (!participanteExists) {
throw new HttpException(`Participante con ID ${id_participante} no encontrado`, HttpStatus.NOT_FOUND);
}
// Obtener todos los registros participante-evento para este participante
const eventosParticipante = await this.participanteEventoRepository.find({
where: { id_participante },
relations: ['evento']
});
return eventosParticipante;
}
async deleteParticipanteEvento(id_participante: number) {
const result = await this.participanteEventoRepository.delete({ id_participante })
@@ -78,4 +142,19 @@ export class ParticipanteEventoService {
return this.participanteEventoRepository.save(participante_evento)
}
async registrarAsistencia(id_participante: number, id_evento: number) {
const participante_eventoFound = await this.participanteEventoRepository.findOne({
where: {
id_participante,
id_evento
}
});
if (!participante_eventoFound) {
return new HttpException('Registro no encontrado', HttpStatus.NOT_FOUND);
}
participante_eventoFound.fecha_asistencia = new Date();
return this.participanteEventoRepository.save(participante_eventoFound);
}
}
+81 -2
View File
@@ -1,33 +1,112 @@
import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export enum TipoPreguntaEnum {
CERRADA = 'Cerrada',
ABIERTA = 'Abierta',
MULTIPLE = 'Multiple'
}
export class OpcionDto {
@ApiProperty({
description: 'Valor de la opción',
example: 'Sí'
})
@IsNotEmpty()
@IsString()
valor: string;
}
export class CreatePreguntaDto {
@ApiProperty({
description: 'Título o texto de la pregunta',
example: '¿Eres parte de la comunidad de la FES Acatlán?'
})
@IsNotEmpty()
@IsString()
titulo: string;
@ApiProperty({
description: 'Indica si la pregunta es obligatoria',
example: true,
default: false
})
@IsOptional()
@IsBoolean()
obligatoria?: boolean;
@ApiProperty({
description: 'Límite de caracteres para respuestas de texto (solo aplica a preguntas tipo Abierta)',
example: 250,
required: false
})
@IsOptional()
@IsNumber()
limite?: number;
@ApiProperty({
description: 'Tipo de pregunta',
enum: Object.values(TipoPreguntaEnum),
enumName: 'TipoPreguntaEnum',
example: TipoPreguntaEnum.CERRADA,
examples: {
'cerrada': {
summary: 'Pregunta de opción única',
value: TipoPreguntaEnum.CERRADA
},
'abierta': {
summary: 'Pregunta de texto libre',
value: TipoPreguntaEnum.ABIERTA
},
'multiple': {
summary: 'Pregunta de selección múltiple',
value: TipoPreguntaEnum.MULTIPLE
}
}
})
@IsNotEmpty()
@IsString()
@IsEnum(TipoPreguntaEnum, {
message: 'El tipo debe ser uno de los siguientes valores: Cerrada, Abierta, Multiple'
})
tipo: string;
@ApiProperty({
description: 'Contador de opciones (se calcula automáticamente)',
required: false
})
@IsOptional()
@IsNumber()
contador_opcion?: number;
@ApiProperty({
description: 'ID de la opción de la que depende esta pregunta (para preguntas condicionadas)',
required: false,
example: null
})
@IsOptional()
@IsNumber()
id_opcion_dependiente?: number;
@ApiProperty({
description: 'Tipo de validación para la respuesta',
required: false,
examples: ['cuenta_alumno', 'correo', 'telefono', 'nombre', 'entero', 'decimal'],
example: 'correo'
})
@IsOptional()
@IsString()
validacion?: string;
@ApiProperty({
description: 'Lista de opciones para preguntas de tipo Cerrada o Multiple',
type: [OpcionDto],
required: false,
examples: [
{ valor: 'Sí' },
{ valor: 'No' }
]
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
+3
View File
@@ -28,6 +28,9 @@ export class Pregunta {
@Column({ type: 'int', nullable: true })
id_opcion_dependiente?: number;
@Column({ nullable: true })
validacion?: string;
@OneToMany(() => SeccionPregunta, seccionPregunta => seccionPregunta.pregunta)
seccionPreguntas: SeccionPregunta[];
+55 -3
View File
@@ -1,5 +1,6 @@
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { applyDecorators } from '@nestjs/common';
import { TipoPreguntaEnum } from './dto/create-pregunta.dto';
export class PreguntaApiDocumentation {
// Decoradores para toda la clase del controlador
@@ -19,9 +20,19 @@ export class PreguntaApiDocumentation {
properties: {
titulo: { type: 'string', example: '¿Eres parte de la comunidad?' },
obligatoria: { type: 'boolean', example: true },
tipo: { type: 'string', example: 'Multiple' },
tipo: {
type: 'string',
enum: Object.values(TipoPreguntaEnum),
description: 'Tipo de pregunta: Cerrada (opción única), Abierta (texto libre), Multiple (varias opciones)',
example: TipoPreguntaEnum.CERRADA
},
contador_opcion: { type: 'number', example: 0 },
id_opcion_dependiente: { type: 'number', example: null },
validacion: {
type: 'string',
description: 'Tipo de validación para la respuesta (cuenta_alumno, correo, telefono, nombre, entero, decimal, etc)',
example: 'correo'
},
opciones: {
type: 'array',
items: {
@@ -32,6 +43,44 @@ export class PreguntaApiDocumentation {
}
}
}
},
examples: {
cerrada: {
summary: 'Pregunta de opción única',
value: {
titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
obligatoria: true,
tipo: TipoPreguntaEnum.CERRADA,
validacion: null,
opciones: [
{ valor: 'Sí' },
{ valor: 'No' }
]
}
},
abierta: {
summary: 'Pregunta de texto libre',
value: {
titulo: '¿Qué mejorarías en nuestro servicio?',
obligatoria: false,
tipo: TipoPreguntaEnum.ABIERTA,
validacion: null
}
},
multiple: {
summary: 'Pregunta de selección múltiple',
value: {
titulo: '¿Qué aspectos del servicio fueron de tu agrado?',
obligatoria: false,
tipo: TipoPreguntaEnum.MULTIPLE,
validacion: null,
opciones: [
{ valor: 'Rapidez' },
{ valor: 'Atención al cliente' },
{ valor: 'Facilidad de uso' }
]
}
}
}
}),
ApiResponse({
@@ -45,7 +94,8 @@ export class PreguntaApiDocumentation {
obligatoria: { type: 'boolean', example: true },
contador_opcion: { type: 'number', example: 0 },
id_tipo_pregunta: { type: 'number', example: 1 },
id_opcion_dependiente: { type: 'number', example: null }
id_opcion_dependiente: { type: 'number', example: null },
validacion: { type: 'string', example: 'correo' }
}
}
}),
@@ -72,7 +122,8 @@ export class PreguntaApiDocumentation {
obligatoria: { type: 'boolean', example: true },
contador_opcion: { type: 'number', example: 0 },
id_tipo_pregunta: { type: 'number', example: 1 },
id_opcion_dependiente: { type: 'number', example: null }
id_opcion_dependiente: { type: 'number', example: null },
validacion: { type: 'string', example: 'correo' }
}
}
}
@@ -105,6 +156,7 @@ export class PreguntaApiDocumentation {
contador_opcion: { type: 'number', example: 0 },
id_tipo_pregunta: { type: 'number', example: 1 },
id_opcion_dependiente: { type: 'number', example: null },
validacion: { type: 'string', example: 'correo' },
opciones: {
type: 'array',
items: {
+2
View File
@@ -44,6 +44,7 @@ export class PreguntaService {
pregunta.id_tipo_pregunta = tipoPregunta.id_tipo;
pregunta.id_opcion_dependiente = createPreguntaDto.id_opcion_dependiente || undefined;
pregunta.contador_opcion = createPreguntaDto.opciones?.length || 0;
pregunta.validacion = createPreguntaDto.validacion || undefined;
const savedPregunta = await queryRunner.manager.save(pregunta);
@@ -158,6 +159,7 @@ export class PreguntaService {
if (updatePreguntaDto.titulo) pregunta.pregunta = updatePreguntaDto.titulo;
if (updatePreguntaDto.obligatoria !== undefined) pregunta.obligatoria = updatePreguntaDto.obligatoria;
if (updatePreguntaDto.id_opcion_dependiente !== undefined) pregunta.id_opcion_dependiente = updatePreguntaDto.id_opcion_dependiente;
if (updatePreguntaDto.validacion !== undefined) pregunta.validacion = updatePreguntaDto.validacion;
// Si se proporciona un nuevo tipo de pregunta, actualizarlo
if (updatePreguntaDto.tipo) {
@@ -1,7 +1,7 @@
import { Opcion } from 'src/opcion/entities/opcion.entity';
import { Pregunta } from 'src/pregunta/entities/pregunta.entity';
import { RespuestaParticipanteCerrada } from 'src/respuesta_participante_cerrada/entities/respuesta_participante_cerrada.entity';
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany } from 'typeorm';
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
@Entity()
@@ -13,14 +13,19 @@ export class PreguntaOpcion {
posicion: number;
@ManyToOne(() => Pregunta)
@JoinColumn({ name: 'id_pregunta' })
pregunta: Pregunta;
@Column()
id_pregunta: number;
@ManyToOne(() => Opcion, opcion => opcion.preguntasOpciones)
@JoinColumn({ name: 'id_opcion' })
opcion: Opcion;
@Column()
id_opcion: number;
@OneToMany(()=> RespuestaParticipanteCerrada, respuestaParticipanteCerrada=>respuestaParticipanteCerrada.preguntaOpcion)
respuestaParticipanteCerrada:RespuestaParticipanteCerrada[];
}
+12 -1
View File
@@ -13,7 +13,7 @@ import { QrService } from './qr.service';
import { Qr } from './qr.entity';
import { CreateQrDto } from './dto/create-qr.dto';
import { UpdateQrDto } from './dto/update.qr.dto';
import { ApiOperation, ApiQuery } from '@nestjs/swagger';
import { ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger';
@Controller('qr')
export class QrController {
@@ -30,6 +30,17 @@ export class QrController {
return this.qrService.generateQRCode(text);
}
@Get('asistencia/:idParticipante/:idEvento')
@ApiOperation({ summary: 'Genera un código QR para asistencia con IDs de participante y evento' })
@ApiParam({ name: 'idParticipante', description: 'ID del participante' })
@ApiParam({ name: 'idEvento', description: 'ID del evento' })
async generateAsistenciaQR(
@Param('idParticipante', ParseIntPipe) idParticipante: number,
@Param('idEvento', ParseIntPipe) idEvento: number
): Promise<string> {
return this.qrService.generateAsistenciaQR(idParticipante, idEvento);
}
@Get()
getQrs(): Promise<Qr[]> {
return this.qrService.getQrs();
+15
View File
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateQrDto } from './dto/create-qr.dto';
import { UpdateQrDto } from './dto/update.qr.dto';
// @ts-ignore
import * as QRCode from 'qrcode';
@Injectable()
@@ -14,6 +15,20 @@ export class QrService {
return await QRCode.toDataURL(text);
}
async generateAsistenciaQR(id_participante: number, id_evento: number): Promise<string> {
// Crear un objeto JSON que contenga los IDs
const qrData = {
id_participante,
id_evento
};
// Convertir el objeto a una cadena JSON
const jsonStr = JSON.stringify(qrData);
// Generar el código QR con la cadena JSON
return await QRCode.toDataURL(jsonStr);
}
async generateBuffer(text: string): Promise<Buffer> {
return await QRCode.toBuffer(text); // Devuelve un buffer para adjuntar en email o stream
}
+1 -1
View File
@@ -8,6 +8,6 @@ import { TipoPregunta } from './entities/tipo_pregunta.entity';
imports: [TypeOrmModule.forFeature([TipoPregunta])],
controllers: [TipoPreguntaController],
providers: [TipoPreguntaService],
exports: [TypeOrmModule]
exports: [TipoPreguntaService, TypeOrmModule]
})
export class TipoPreguntaModule {}
+56 -3
View File
@@ -1,6 +1,59 @@
import { Injectable } from '@nestjs/common';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateTipoPreguntaDto } from './dto/create-tipo_pregunta.dto';
import { UpdateTipoPreguntaDto } from './dto/update-tipo_pregunta.dto';
import { TipoPregunta } from './entities/tipo_pregunta.entity';
@Injectable()
export class TipoPreguntaService {
// Métodos del servicio
export class TipoPreguntaService implements OnModuleInit {
constructor(
@InjectRepository(TipoPregunta)
private tipoPreguntaRepository: Repository<TipoPregunta>,
) {}
async onModuleInit() {
await this.seedTipoPreguntas();
}
private async seedTipoPreguntas() {
const defaultTipos = [
{ tipo_pregunta: 'Cerrada' },
{ tipo_pregunta: 'Abierta' },
{ tipo_pregunta: 'Multiple' },
{ tipo_pregunta: 'Texto' }
];
for (const tipo of defaultTipos) {
const existingTipo = await this.tipoPreguntaRepository.findOne({
where: { tipo_pregunta: tipo.tipo_pregunta },
});
if (!existingTipo) {
await this.tipoPreguntaRepository.save(tipo);
}
}
}
create(createTipoPreguntaDto: CreateTipoPreguntaDto) {
return this.tipoPreguntaRepository.save(createTipoPreguntaDto);
}
findAll() {
return this.tipoPreguntaRepository.find();
}
findOne(id: number) {
return this.tipoPreguntaRepository.findOne({
where: { id_tipo: id }
});
}
update(id: number, updateTipoPreguntaDto: UpdateTipoPreguntaDto) {
return this.tipoPreguntaRepository.update(id, updateTipoPreguntaDto);
}
remove(id: number) {
return this.tipoPreguntaRepository.delete(id);
}
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ValidadorRespuestasService } from './validador-respuestas.service';
@Module({
providers: [ValidadorRespuestasService],
exports: [ValidadorRespuestasService]
})
export class ValidacionesModule {}
@@ -0,0 +1,63 @@
import { Injectable } from '@nestjs/common';
import { validarRespuesta, ResultadoValidacion } from './validador';
/**
* Servicio para validar respuestas de cuestionarios
* Utiliza los validadores específicos según el tipo de validación requerido
*/
@Injectable()
export class ValidadorRespuestasService {
/**
* Valida una respuesta de acuerdo al tipo de validación de la pregunta
* @param respuesta Texto de la respuesta a validar
* @param tipoValidacion Tipo de validación ('correo', 'telefono', 'nombre', etc.)
* @returns Resultado de la validación (válido y mensaje)
*/
validarRespuesta(respuesta: string, tipoValidacion: string): ResultadoValidacion {
// Si no hay tipo de validación, la respuesta se considera válida
if (!tipoValidacion) {
return {
valido: true,
mensaje: 'No se requiere validación específica'
};
}
return validarRespuesta(respuesta, tipoValidacion);
}
/**
* Valida múltiples respuestas basadas en sus tipos de validación
* @param respuestas Array de respuestas a validar
* @param tiposValidacion Array de tipos de validación correspondientes
* @returns Object con resultados de validación usando las posiciones como claves
*/
validarRespuestas(
respuestas: string[],
tiposValidacion: string[]
): { [key: number]: ResultadoValidacion } {
const resultados: { [key: number]: ResultadoValidacion } = {};
respuestas.forEach((respuesta, index) => {
if (index < tiposValidacion.length) {
resultados[index] = this.validarRespuesta(respuesta, tiposValidacion[index]);
} else {
// Si no hay un tipo de validación correspondiente, se asume válida
resultados[index] = {
valido: true,
mensaje: 'No se requiere validación específica'
};
}
});
return resultados;
}
/**
* Comprueba si todas las respuestas son válidas
* @param resultados Resultados de validación
* @returns true si todas las validaciones son correctas, false en caso contrario
*/
todasValidas(resultados: { [key: number]: ResultadoValidacion }): boolean {
return Object.values(resultados).every(resultado => resultado.valido);
}
}
+290
View File
@@ -0,0 +1,290 @@
/**
* Módulo de validación para respuestas de cuestionarios
* Este módulo puede ser utilizado tanto en backend (NestJS) como en frontend (Angular, React, etc.)
*/
// Interfaz para el resultado de validación
export interface ResultadoValidacion {
valido: boolean;
mensaje: string;
}
// Clase base para todos los validadores
export abstract class Validador {
abstract validar(valor: string): ResultadoValidacion;
}
// Clase para validar correos electrónicos
export class ValidadorCorreo extends Validador {
validar(valor: string): ResultadoValidacion {
if (!valor) {
return {
valido: false,
mensaje: 'El correo electrónico es requerido'
};
}
// Expresión regular para validar correos
// Valida el formato básico de correos: usuario@dominio.extensión
const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
if (!regex.test(valor)) {
return {
valido: false,
mensaje: 'El formato del correo electrónico no es válido'
};
}
// Para correos institucionales (opcional)
if (this.validarDominioInstitucional) {
const dominios = ['acatlan.unam.mx', 'unam.mx', 'comunidad.unam.mx'];
const dominio = valor.split('@')[1];
if (!dominios.includes(dominio)) {
return {
valido: false,
mensaje: 'Debe ser un correo institucional (@acatlan.unam.mx, @unam.mx, @comunidad.unam.mx)'
};
}
}
return {
valido: true,
mensaje: 'Correo electrónico válido'
};
}
// Propiedad que permite configurar si se validan sólo correos institucionales
constructor(public validarDominioInstitucional: boolean = false) {
super();
}
}
// Clase para validar números telefónicos
export class ValidadorTelefono extends Validador {
validar(valor: string): ResultadoValidacion {
if (!valor) {
return {
valido: false,
mensaje: 'El número telefónico es requerido'
};
}
// Eliminar espacios, guiones y paréntesis para la validación
const numeroLimpio = valor.replace(/[\s\-()]/g, '');
// Verificar que solo contenga dígitos
if (!/^\d+$/.test(numeroLimpio)) {
return {
valido: false,
mensaje: 'El número telefónico solo debe contener dígitos'
};
}
// Verificar longitud (para México, 10 dígitos)
if (numeroLimpio.length !== 10) {
return {
valido: false,
mensaje: 'El número telefónico debe tener 10 dígitos'
};
}
return {
valido: true,
mensaje: 'Número telefónico válido'
};
}
}
// Clase para validar nombres
export class ValidadorNombre extends Validador {
validar(valor: string): ResultadoValidacion {
const regex = new RegExp(
'/^[A-Za-zÁÉÍÓÚáéíóúÑñ]+(?: [A-Za-zÁÉÍÓÚáéíóúÑñ]+)*$/'
);
if (!valor) {
return {
valido: false,
mensaje: 'El nombre es requerido'
};
}
// Verificar longitud mínima
if (valor.trim().length < 2) {
return {
valido: false,
mensaje: 'El nombre debe tener al menos 2 caracteres'
};
}
// Verificar que solo contenga letras y espacios
if (regex.test(valor)) {
return {
valido: false,
mensaje: 'El nombre solo debe contener letras y espacios'
};
}
return {
valido: true,
mensaje: 'Nombre válido'
};
}
}
// Clase para validar números enteros
export class ValidadorEntero extends Validador {
constructor(
private min: number = Number.MIN_SAFE_INTEGER,
private max: number = Number.MAX_SAFE_INTEGER
) {
super();
}
validar(valor: string): ResultadoValidacion {
if (!valor) {
return {
valido: false,
mensaje: 'El valor numérico es requerido'
};
}
// Verificar que sea un número entero
if (!/^-?\d+$/.test(valor)) {
return {
valido: false,
mensaje: 'El valor debe ser un número entero'
};
}
const numero = parseInt(valor, 10);
// Verificar rango
if (numero < this.min || numero > this.max) {
return {
valido: false,
mensaje: `El valor debe estar entre ${this.min} y ${this.max}`
};
}
return {
valido: true,
mensaje: 'Número entero válido'
};
}
}
// Clase para validar números decimales
export class ValidadorDecimal extends Validador {
constructor(
private min: number = Number.MIN_SAFE_INTEGER,
private max: number = Number.MAX_SAFE_INTEGER,
private decimales: number = 2
) {
super();
}
validar(valor: string): ResultadoValidacion {
if (!valor) {
return {
valido: false,
mensaje: 'El valor numérico es requerido'
};
}
// Verificar que sea un número decimal válido
if (!/^-?\d+(\.\d+)?$/.test(valor)) {
return {
valido: false,
mensaje: 'El valor debe ser un número decimal válido'
};
}
const numero = parseFloat(valor);
// Verificar rango
if (numero < this.min || numero > this.max) {
return {
valido: false,
mensaje: `El valor debe estar entre ${this.min} y ${this.max}`
};
}
// Verificar precisión decimal
const partes = valor.split('.');
if (partes.length > 1 && partes[1].length > this.decimales) {
return {
valido: false,
mensaje: `El valor debe tener máximo ${this.decimales} decimales`
};
}
return {
valido: true,
mensaje: 'Número decimal válido'
};
}
}
// Clase para validar cuentas de alumno (formato específico UNAM - 9 dígitos)
export class ValidadorCuentaAlumno extends Validador {
validar(valor: string): ResultadoValidacion {
if (!valor) {
return {
valido: false,
mensaje: 'La cuenta de alumno es requerida'
};
}
// Formato de cuenta UNAM: 9 dígitos para todos los alumnos
if (!/^\d{9}$/.test(valor)) {
return {
valido: false,
mensaje: 'La cuenta de alumno debe tener exactamente 9 dígitos numéricos'
};
}
return {
valido: true,
mensaje: 'Cuenta de alumno válida'
};
}
}
// Fábrica de validadores para crear el validador apropiado según el tipo
export class FabricaValidadores {
static crear(tipo: string): Validador | null {
switch (tipo?.toLowerCase()) {
case 'correo':
return new ValidadorCorreo();
case 'correo_institucional':
return new ValidadorCorreo(true);
case 'telefono':
return new ValidadorTelefono();
case 'nombre':
return new ValidadorNombre();
case 'entero':
return new ValidadorEntero();
case 'decimal':
return new ValidadorDecimal();
case 'cuenta_alumno':
return new ValidadorCuentaAlumno();
default:
return null;
}
}
}
// Función auxiliar para validar respuestas basada en el tipo de validación
export function validarRespuesta(respuesta: string, tipoValidacion: string): ResultadoValidacion {
const validador = FabricaValidadores.crear(tipoValidacion);
if (!validador) {
return {
valido: true, // Si no hay validador, consideramos válida la respuesta
mensaje: 'No se requiere validación específica'
};
}
return validador.validar(respuesta);
}
+22 -7
View File
@@ -5,6 +5,7 @@ export const feriaSexualidad = {
fecha_inicio: '2025-03-07T00:00:01',
fecha_fin: '2025-03-18T23:59:59',
id_tipo_cuestionario: 1,
evento: 'Feria de la Sexualidad',
secciones: [
{
titulo: 'Información Personal',
@@ -13,29 +14,42 @@ export const feriaSexualidad = {
preguntas: [
{
titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
tipo: 'Multiple',
tipo: 'Cerrada',
opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true,
},
{
titulo: 'Correo electrónico',
obligatoria: true,
tipo: 'Abierta',
limite: 250,
validacion: 'correo',
},
{
titulo: 'Numero de cuenta',
obligatoria: true,
tipo: 'Numero',
tipo: 'Abierta',
limite: 250,
validacion: 'cuenta_alumno',
},
{
titulo: 'Nombre(s)',
obligatoria: true,
tipo: 'Texto',
tipo: 'Abierta',
limite: 250,
validacion: 'nombre',
},
{
titulo: 'Apellidos',
obligatoria: true,
tipo: 'Texto',
tipo: 'Abierta',
limite: 250,
validacion: 'nombre',
},
{
titulo: 'genero',
titulo: 'nero',
obligatoria: true,
tipo: 'Radio',
tipo: 'Cerrada',
opciones: [
{ valor: 'Masculino' },
{ valor: 'Femenino' },
@@ -44,8 +58,9 @@ export const feriaSexualidad = {
},
{
titulo: 'Institución de procedencia',
tipo: 'Abierto',
tipo: 'Abierta',
obligatoria: true,
limite: 250,
},
],
},