Merge pull request 'eithan' (#14) from eithan into develop

Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2025-04-04 14:43:54 -06:00
24 changed files with 979 additions and 74 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');
+15 -1
View File
@@ -24,10 +24,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:
+2
View File
@@ -23,6 +23,7 @@ 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';
@Module({
@@ -77,6 +78,7 @@ import { ParticipanteEventoModule } from './participante_evento/participante_eve
AdministradorModule,
AsistenciaModule,
ParticipanteEventoModule,
ValidacionesModule,
],
controllers: [AppController],
@@ -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 },
+2
View File
@@ -139,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);
@@ -301,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
@@ -75,6 +75,7 @@ export const FormularioFeriaSchema = {
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'
@@ -93,6 +94,7 @@ export const FormularioFeriaSchema = {
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'
@@ -12,6 +12,7 @@ import { RespuestaParticipanteAbierta } from '../respuesta_participante_abierta/
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: [
@@ -26,7 +27,8 @@ import { Opcion } from '../opcion/entities/opcion.entity';
Opcion
]),
CuestionarioModule,
ParticipanteModule
ParticipanteModule,
ValidacionesModule
],
controllers: [CuestionarioRespondidoController],
providers: [CuestionarioRespondidoService],
@@ -12,6 +12,7 @@ import { RespuestaParticipanteAbierta } from '../respuesta_participante_abierta/
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';
@@ -35,6 +36,7 @@ export class CuestionarioRespondidoService {
@InjectRepository(Opcion)
private opcionRepository: Repository<Opcion>,
private dataSource: DataSource,
private validadorRespuestasService: ValidadorRespuestasService,
) {}
create(createCuestionarioRespondidoDto: CreateCuestionarioRespondidoDto) {
@@ -90,6 +92,18 @@ export class CuestionarioRespondidoService {
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;
@@ -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,22 +3,92 @@ 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);
}
@Get(':id')
getParticipanteEvento(@Param('id', ParseIntPipe) id: number) {
return this.participanteEventoService.getParticipanteEvento(id)
@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,
@@ -27,11 +97,61 @@ export class ParticipanteEventoController {
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,
@@ -40,11 +160,25 @@ export class ParticipanteEventoController {
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)
@@ -69,6 +69,54 @@ export class ParticipanteEventoService {
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 })
+10
View File
@@ -88,6 +88,16 @@ export class CreatePreguntaDto {
@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],
+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[];
+14 -3
View File
@@ -28,6 +28,11 @@ export class PreguntaApiDocumentation {
},
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: {
@@ -46,6 +51,7 @@ export class PreguntaApiDocumentation {
titulo: '¿Eres parte de la comunidad de la FES Acatlán?',
obligatoria: true,
tipo: TipoPreguntaEnum.CERRADA,
validacion: null,
opciones: [
{ valor: 'Sí' },
{ valor: 'No' }
@@ -57,7 +63,8 @@ export class PreguntaApiDocumentation {
value: {
titulo: '¿Qué mejorarías en nuestro servicio?',
obligatoria: false,
tipo: TipoPreguntaEnum.ABIERTA
tipo: TipoPreguntaEnum.ABIERTA,
validacion: null
}
},
multiple: {
@@ -66,6 +73,7 @@ export class PreguntaApiDocumentation {
titulo: '¿Qué aspectos del servicio fueron de tu agrado?',
obligatoria: false,
tipo: TipoPreguntaEnum.MULTIPLE,
validacion: null,
opciones: [
{ valor: 'Rapidez' },
{ valor: 'Atención al cliente' },
@@ -86,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' }
}
}
}),
@@ -113,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' }
}
}
}
@@ -146,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) {
+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);
}
}
+287
View File
@@ -0,0 +1,287 @@
/**
* 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 {
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 (!/^[a-zA-ZáéíóúÁÉÍÓÚñÑüÜ\\s]+$/.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);
}
+4
View File
@@ -23,24 +23,28 @@ export const feriaSexualidad = {
obligatoria: true,
tipo: 'Abierta',
limite: 250,
validacion: 'correo',
},
{
titulo: 'Numero de cuenta',
obligatoria: true,
tipo: 'Abierta',
limite: 250,
validacion: 'cuenta_alumno',
},
{
titulo: 'Nombre(s)',
obligatoria: true,
tipo: 'Abierta',
limite: 250,
validacion: 'nombre',
},
{
titulo: 'Apellidos',
obligatoria: true,
tipo: 'Abierta',
limite: 250,
validacion: 'nombre',
},
{
titulo: 'Género',