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:
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user