Merge pull request 'respuestas cerradas y multiples por id, limite de respuestas abiertas a 250' (#13) from eithan into develop

Reviewed-on: #13
This commit was merged in pull request #13.
This commit is contained in:
2025-04-03 15:00:36 -06:00
6 changed files with 135 additions and 46 deletions
+22 -2
View File
@@ -23,7 +23,7 @@ export const FormularioFeriaSchema = {
posicion: 1, posicion: 1,
seccion: { seccion: {
id_seccion: 100, id_seccion: 100,
contador_pregunta: 2, contador_pregunta: 3,
descripcion: 'Información básica', descripcion: 'Información básica',
titulo: 'Datos personales' titulo: 'Datos personales'
}, },
@@ -65,8 +65,26 @@ export const FormularioFeriaSchema = {
} }
}, },
{ {
id_seccion_pregunta: 1001, id_seccion_pregunta: 1005,
posicion: 2, 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
tipo_pregunta: {
id_tipo: 2,
tipo_pregunta: 'Abierta'
},
opciones: []
}
},
{
id_seccion_pregunta: 1001,
posicion: 3,
pregunta: { pregunta: {
id_pregunta: 102, id_pregunta: 102,
pregunta: 'Número de cuenta (solo para estudiantes de la FES Acatlán)', pregunta: 'Número de cuenta (solo para estudiantes de la FES Acatlán)',
@@ -74,6 +92,7 @@ export const FormularioFeriaSchema = {
obligatoria: false, obligatoria: false,
id_tipo_pregunta: 2, id_tipo_pregunta: 2,
id_opcion_dependiente: 90001, // Dependiente de la respuesta "Sí" en la pregunta anterior id_opcion_dependiente: 90001, // Dependiente de la respuesta "Sí" en la pregunta anterior
limite: 250, // Límite de caracteres para respuestas de texto
tipo_pregunta: { tipo_pregunta: {
id_tipo: 2, id_tipo: 2,
tipo_pregunta: 'Abierta' tipo_pregunta: 'Abierta'
@@ -166,6 +185,7 @@ export const FormularioFeriaSchema = {
obligatoria: false, obligatoria: false,
id_tipo_pregunta: 2, id_tipo_pregunta: 2,
id_opcion_dependiente: null, id_opcion_dependiente: null,
limite: 250, // Límite de caracteres para respuestas de texto
tipo_pregunta: { tipo_pregunta: {
id_tipo: 2, id_tipo: 2,
tipo_pregunta: 'Abierta' tipo_pregunta: 'Abierta'
@@ -5,7 +5,20 @@ export class CuestionarioRespondidoApiDocumentation {
static ApiSubmitRespuestas = ApiOperation({ static ApiSubmitRespuestas = ApiOperation({
summary: 'Enviar respuestas a un cuestionario', 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.' 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({ static ApiSubmitResponse201 = ApiResponse({
@@ -26,7 +39,7 @@ export class CuestionarioRespondidoApiDocumentation {
schema: { schema: {
properties: { properties: {
statusCode: { type: 'number', example: 400 }, statusCode: { type: 'number', example: 400 },
message: { type: 'string', example: 'No se encontró la opción \'Opción inválida\' para la pregunta 101' }, message: { type: 'string', example: 'La opción con ID 999 no está asociada a la pregunta 101' },
error: { type: 'string', example: 'Bad Request' } error: { type: 'string', example: 'Bad Request' }
} }
} }
@@ -51,29 +64,32 @@ export class CuestionarioRespondidoApiDocumentation {
id_formulario: 1, id_formulario: 1,
correo: 'estudiante@acatlan.unam.mx', correo: 'estudiante@acatlan.unam.mx',
respuestas: [ respuestas: [
{ id_pregunta: 101, valor: 'Si' }, // ¿Eres parte de la comunidad de la FES Acatlán? { id_pregunta: 101, valor: 3 }, // Opción "Si" (usando el ID de la opción)
{ id_pregunta: 102, valor: '123456789' }, // Numero de cuenta { id_pregunta: 102, valor: 'estudiante@acatlan.unam.mx' }, // Correo electrónico
{ id_pregunta: 106, valor: 'Prefiero no decirlo' } // Género { 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' fecha_envio: '2025-04-02T13:45:00'
}, },
description: 'Datos de un estudiante de la FES Acatlán que proporciona su número de cuenta.' description: 'Datos de un estudiante de la FES Acatlán con respuestas abiertas y opciones por ID.'
}, },
externalExample: { externalExample: {
summary: 'Ejemplo de respuesta para un participante externo', summary: 'Ejemplo de respuesta para un participante externo con selección múltiple',
value: { value: {
id_formulario: 1, id_formulario: 1,
correo: 'externo@ejemplo.com', correo: 'externo@ejemplo.com',
respuestas: [ respuestas: [
{ id_pregunta: 101, valor: 'No' }, // ¿Eres parte de la comunidad de la FES Acatlán? { 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: 103, valor: 'Juan Carlos' }, // Nombre(s)
{ id_pregunta: 104, valor: 'Ramírez López' }, // Apellidos { id_pregunta: 104, valor: 'Ramírez López' }, // Apellidos
{ id_pregunta: 106, valor: 'Prefiero no decirlo' }, // Género { 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 { id_pregunta: 107, valor: 'UAM Azcapotzalco' } // Institución de procedencia
], ],
fecha_envio: '2025-04-02T13:45:00' fecha_envio: '2025-04-02T13:45:00'
}, },
description: 'Datos de un participante externo que proporciona su nombre e institución de procedencia.' description: 'Datos de un participante externo con respuestas abiertas, opciones por ID y selección múltiple.'
} }
}; };
} }
@@ -100,41 +100,46 @@ export class CuestionarioRespondidoService {
// Determinar tipo de pregunta y guardar respuesta adecuadamente // Determinar tipo de pregunta y guardar respuesta adecuadamente
if (esPreguntaAbierta || esPreguntaTipoAbierta) { if (esPreguntaAbierta || esPreguntaTipoAbierta) {
// Respuesta abierta (texto) // 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(); const respuestaAbierta = new RespuestaParticipanteAbierta();
respuestaAbierta.pregunta = pregunta; respuestaAbierta.pregunta = pregunta;
respuestaAbierta.respuesta = respuestaDto.valor; respuestaAbierta.respuesta = respuestaTexto;
respuestaAbierta.cuestionarioRespondido = savedCuestionarioRespondido; respuestaAbierta.cuestionarioRespondido = savedCuestionarioRespondido;
await queryRunner.manager.save(respuestaAbierta); await queryRunner.manager.save(respuestaAbierta);
} else { } else {
// Respuesta cerrada (opción) // Respuesta cerrada (opción)
// Buscar la opción correspondiente // Verificar si es una respuesta múltiple (array de IDs)
const opciones = await this.opcionRepository.find({ const opcionIds = Array.isArray(respuestaDto.valor)
where: { opcion: respuestaDto.valor } ? respuestaDto.valor
}); : [respuestaDto.valor];
if (opciones.length === 0) { // Procesar cada ID de opción
throw new BadRequestException(`No se encontró la opción '${respuestaDto.valor}' para la pregunta ${respuestaDto.id_pregunta}`); 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);
} }
// Buscar la relación entre pregunta y 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: opciones[0].id_opcion })
.getMany();
if (preguntaOpciones.length === 0) {
throw new BadRequestException(`La opción '${respuestaDto.valor}' 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);
} }
} }
@@ -1,6 +1,16 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsDateString, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; import {
import { Type } from 'class-transformer'; IsArray,
IsDateString,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
ValidateNested,
ValidateIf,
MaxLength
} from 'class-validator';
import { Type, Transform } from 'class-transformer';
export class RespuestaDto { export class RespuestaDto {
@ApiProperty({ @ApiProperty({
@@ -11,11 +21,25 @@ export class RespuestaDto {
id_pregunta: number; id_pregunta: number;
@ApiProperty({ @ApiProperty({
description: 'Valor de la respuesta', description: 'Valor de la respuesta según tipo de pregunta',
example: 'Si' 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'
}
]
}) })
@IsString() @ValidateIf(o => typeof o.valor === 'string')
valor: string; @MaxLength(250, { message: 'Las respuestas de texto no pueden exceder 250 caracteres' })
valor: string | number | number[];
} }
export class SubmitRespuestasDto { export class SubmitRespuestasDto {
@@ -32,6 +56,7 @@ export class SubmitRespuestasDto {
}) })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MaxLength(250, { message: 'El correo electrónico no puede exceder 250 caracteres' })
correo: string; correo: string;
@ApiProperty({ @ApiProperty({
@@ -40,11 +65,15 @@ export class SubmitRespuestasDto {
examples: [ examples: [
{ {
id_pregunta: 101, id_pregunta: 101,
valor: 'Si' valor: 'Texto para pregunta abierta'
}, },
{ {
id_pregunta: 102, id_pregunta: 102,
valor: '123456789' 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
} }
] ]
}) })
+9
View File
@@ -36,6 +36,15 @@ export class CreatePreguntaDto {
@IsBoolean() @IsBoolean()
obligatoria?: boolean; 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({ @ApiProperty({
description: 'Tipo de pregunta', description: 'Tipo de pregunta',
enum: Object.values(TipoPreguntaEnum), enum: Object.values(TipoPreguntaEnum),
+10
View File
@@ -18,20 +18,29 @@ export const feriaSexualidad = {
opciones: [{ valor: 'Si' }, { valor: 'No' }], opciones: [{ valor: 'Si' }, { valor: 'No' }],
obligatoria: true, obligatoria: true,
}, },
{
titulo: 'Correo electrónico',
obligatoria: true,
tipo: 'Abierta',
limite: 250,
},
{ {
titulo: 'Numero de cuenta', titulo: 'Numero de cuenta',
obligatoria: true, obligatoria: true,
tipo: 'Abierta', tipo: 'Abierta',
limite: 250,
}, },
{ {
titulo: 'Nombre(s)', titulo: 'Nombre(s)',
obligatoria: true, obligatoria: true,
tipo: 'Abierta', tipo: 'Abierta',
limite: 250,
}, },
{ {
titulo: 'Apellidos', titulo: 'Apellidos',
obligatoria: true, obligatoria: true,
tipo: 'Abierta', tipo: 'Abierta',
limite: 250,
}, },
{ {
titulo: 'Género', titulo: 'Género',
@@ -47,6 +56,7 @@ export const feriaSexualidad = {
titulo: 'Institución de procedencia', titulo: 'Institución de procedencia',
tipo: 'Abierta', tipo: 'Abierta',
obligatoria: true, obligatoria: true,
limite: 250,
}, },
], ],
}, },