From d652f9f0129aa06495716b044244cbdd0c7501d0 Mon Sep 17 00:00:00 2001 From: jorgemike Date: Fri, 13 Jun 2025 22:41:20 -0600 Subject: [PATCH 01/14] muchos cambios :'v --- env.example.txt => .env.example | 4 - Dockerfile | 2 +- README.md | 131 +--- docker-compose.yml | 22 +- package-lock.json | 27 +- package.json | 3 +- src/administrador/administrador.controller.ts | 123 ++-- .../administrador.documentation.ts | 253 ++++---- src/administrador/administrador.entity.ts | 34 -- src/administrador/administrador.module.ts | 2 +- src/administrador/administrador.service.ts | 309 +++++----- .../entities/administrador.entity.ts | 20 + src/alumnos/alumnos.module.ts | 42 +- src/app.module.ts | 94 ++- src/asistencia/asistencia.controller.ts | 2 +- src/asistencia/asistencia.entity.ts | 41 -- src/asistencia/asistencia.module.ts | 2 +- src/asistencia/asistencia.service.ts | 2 +- src/asistencia/entities/asistencia.entity.ts | 25 + src/auth/auth.module.ts | 18 +- src/auth/jwt.strategy.ts | 2 +- .../cuestionario.controller.spec.ts | 20 - src/cuestionario/cuestionario.controller.ts | 75 ++- src/cuestionario/cuestionario.module.ts | 2 +- src/cuestionario/cuestionario.service.spec.ts | 18 - src/cuestionario/cuestionario.service.ts | 531 ++++++++++++----- .../{ => docs}/cuestionario.documentation.ts | 211 ++++--- .../docs/cuestionario.examples.ts | 139 +++++ .../dto/create-cuestionario.dto.ts | 47 +- src/cuestionario/dto/formulario.schema.ts | 6 - .../entities/cuestionario.entity.ts | 64 +- ...cuestionario_respondido.controller.spec.ts | 20 - .../cuestionario_respondido.controller.ts | 12 +- .../cuestionario_respondido.documentation.ts | 154 +++-- .../cuestionario_respondido.module.ts | 8 +- .../cuestionario_respondido.service.spec.ts | 18 - .../cuestionario_respondido.service.ts | 557 ++++++++++++------ .../dto/submit-respuestas.dto.ts | 75 +-- .../cuestionario_respondido.entity.ts | 2 +- .../entities/cuestionario_seccion.entity.ts | 1 - src/db/typeorm.config.ts | 37 ++ src/docs/swagger.config.ts | 4 +- src/emails/registro-evento.ts | 44 ++ src/evento/docs/evento.documentation.ts | 68 +++ src/evento/docs/evento.examples.ts | 57 ++ src/evento/dto/create-evento.dto.ts | 27 +- src/evento/dto/update.evento.dto.ts | 27 +- src/evento/entities/evento.entity.ts | 37 ++ src/evento/evento.controller.ts | 112 +++- src/evento/evento.entity.ts | 41 -- src/evento/evento.module.ts | 2 +- src/evento/evento.service.ts | 147 +++-- src/main.ts | 59 +- src/opcion/entities/opcion.entity.ts | 8 +- .../dto/create-participante.dto.ts | 9 - .../entities/participante.entity.ts | 31 + src/participante/participante.controller.ts | 2 +- src/participante/participante.entity.ts | 49 -- src/participante/participante.module.ts | 2 +- src/participante/participante.service.ts | 256 ++++---- .../entities/participante_evento.entity.ts | 40 ++ .../participante_evento.controller.ts | 404 +++++++------ .../participante_evento.entity.ts | 41 -- .../participante_evento.module.ts | 14 +- .../participante_evento.service.ts | 267 +++++---- src/pregunta/dto/create-pregunta.dto.ts | 27 +- src/pregunta/entities/pregunta.entity.ts | 44 +- src/pregunta/pregunta.controller.spec.ts | 20 - src/pregunta/pregunta.documentation.ts | 155 ++--- src/pregunta/pregunta.service.spec.ts | 18 - src/pregunta/pregunta.service.ts | 2 - .../respuesta_participante_abierta.entity.ts | 17 +- .../respuesta_participante_cerrada.entity.ts | 23 +- src/seccion/seccion.service.ts | 92 +-- .../entities/tipo_cuestionario.entity.ts | 23 +- .../entities/tipo_pregunta.entity.ts | 15 +- src/tipo_pregunta/tipo_pregunta.service.ts | 17 +- src/tipo_user/entities/tipo_user.entity.ts | 15 + src/tipo_user/tipo_user.controller.ts | 2 +- src/tipo_user/tipo_user.entity.ts | 28 - src/tipo_user/tipo_user.module.ts | 6 +- src/tipo_user/tipo_user.service.ts | 139 ++--- utils/get_formulario_feria.ts | 4 - 83 files changed, 3289 insertions(+), 2261 deletions(-) rename env.example.txt => .env.example (96%) delete mode 100644 src/administrador/administrador.entity.ts create mode 100644 src/administrador/entities/administrador.entity.ts delete mode 100644 src/asistencia/asistencia.entity.ts create mode 100644 src/asistencia/entities/asistencia.entity.ts delete mode 100644 src/cuestionario/cuestionario.controller.spec.ts delete mode 100644 src/cuestionario/cuestionario.service.spec.ts rename src/cuestionario/{ => docs}/cuestionario.documentation.ts (51%) create mode 100644 src/cuestionario/docs/cuestionario.examples.ts delete mode 100644 src/cuestionario_respondido/cuestionario_respondido.controller.spec.ts delete mode 100644 src/cuestionario_respondido/cuestionario_respondido.service.spec.ts create mode 100644 src/db/typeorm.config.ts create mode 100644 src/emails/registro-evento.ts create mode 100644 src/evento/docs/evento.documentation.ts create mode 100644 src/evento/docs/evento.examples.ts create mode 100644 src/evento/entities/evento.entity.ts delete mode 100644 src/evento/evento.entity.ts create mode 100644 src/participante/entities/participante.entity.ts delete mode 100644 src/participante/participante.entity.ts create mode 100644 src/participante_evento/entities/participante_evento.entity.ts delete mode 100644 src/participante_evento/participante_evento.entity.ts delete mode 100644 src/pregunta/pregunta.controller.spec.ts delete mode 100644 src/pregunta/pregunta.service.spec.ts create mode 100644 src/tipo_user/entities/tipo_user.entity.ts delete mode 100644 src/tipo_user/tipo_user.entity.ts diff --git a/env.example.txt b/.env.example similarity index 96% rename from env.example.txt rename to .env.example index 14564ff..35d6bb5 100644 --- a/env.example.txt +++ b/.env.example @@ -1,7 +1,5 @@ PORT=4200 - - db_type=mysql db_host=mariadb # <-- uso del nombre del servicio, NO IP db_port=3306 @@ -9,6 +7,4 @@ 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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2ac73f4..c8a0157 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ WORKDIR /app COPY package*.json ./ RUN npm install # Instalar explícitamente las dependencias de autenticación -RUN npm install bcryptjs @types/bcryptjs @nestjs/jwt @nestjs/passport passport passport-jwt @types/passport-jwt +RUN npm i COPY . . diff --git a/README.md b/README.md index 35f1236..e2d5f64 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,26 @@ -

- Nest Logo -

+El Sistema de Registros es una plataforma diseñada para gestionar eventos que requieren registro y control de asistencia mediante formularios personalizados. -[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 -[circleci-url]: https://circleci.com/gh/nestjs/nest +Cada evento puede estar compuesto por varios subeventos (como conferencias, talleres o sesiones), y los participantes deben registrarse en aquellos que sean de su interés. Posteriormente, el sistema permite llevar un pase de lista para verificar la asistencia. -

A progressive Node.js framework for building efficient and scalable server-side applications.

-

-NPM Version -Package License -NPM Downloads -CircleCI -Coverage -Discord -Backers on Open Collective -Sponsors on Open Collective - Donate us - Support us - Follow us on Twitter -

- +📌 Tablas principales: +Evento: Representa un evento principal que requiere control de asistencia. Ejemplo: Data & AI Forum 2025. -## Description +Cuestionario: Corresponde a un subevento dentro de un evento. Por ejemplo, dentro del evento Data & AI Forum, puede haber varias conferencias y sesiones en paralelo, cada una representada como un Cuestionario. -[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. +Participante: Representa a una persona registrada en uno o varios subeventos (Cuestionario). Su asistencia puede ser verificada posteriormente. -## Project setup +```sql +Table Evento { + id_evento int [pk, increment] + tipo_evento varchar(50) + nombre_evento varchar(200) + descripcion_evento varchar(500) + fecha_inicio datetime [default: `CURRENT_TIMESTAMP`] + fecha_fin datetime [default: `CURRENT_TIMESTAMP`] + asistencias int + banner varchar -```bash -$ npm install + Note: 'Tabla que representa un evento general, como un foro o conferencia.' +} ``` -## Compile and run the project - -```bash -# development -$ npm run start - -# watch mode -$ npm run start:dev - -# production mode -$ npm run start:prod -``` - -## Run tests - -```bash -# unit tests -$ npm run test - -# e2e tests -$ npm run test:e2e - -# test coverage -$ npm run test:cov -``` - -## Deployment - -When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information. - -If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps: - -```bash -$ npm install -g mau -$ mau deploy -``` - -With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. - -## Resources - -Check out a few resources that may come in handy when working with NestJS: - -- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. -- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). -- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). -- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. -- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). -- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). -- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). -- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). - -## Support - -Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). - -## Stay in touch - -- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) -- Website - [https://nestjs.com](https://nestjs.com/) -- Twitter - [@nestframework](https://twitter.com/nestframework) - -## 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'); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index d933344..c811aec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: MARIADB_DATABASE: formularios_test volumes: - mariadb_data:/var/lib/mysql - - ./init-scripts:/docker-entrypoint-initdb.d + - ./init-scripts:/docker-entrypoint-initdb.d # Base de datos simulada para escolares restart: unless-stopped api: @@ -32,16 +32,16 @@ services: - front: - build: ../formularios_front - container_name: formularios_front - restart: always - depends_on: - - api - env_file: - - ../formularios_front/.env - ports: - - "4202:3000" +# front: +# build: ../formularios_front +# container_name: formularios_front +# restart: always +# depends_on: +# - api +# env_file: +# - ../formularios_front/.env +# ports: +# - "4202:3000" volumes: diff --git a/package-lock.json b/package-lock.json index 5e57e9d..4e4a26b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@nestjs/mapped-types": "*", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", - "@nestjs/swagger": "^11.1.0", + "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.0", "@types/bcrypt": "^5.0.2", "@types/bcryptjs": "^2.4.6", @@ -44,6 +44,7 @@ "@swc/core": "^1.10.7", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", + "@types/multer": "^1.4.13", "@types/node": "^22.10.7", "@types/qrcode": "^1.5.5", "@types/supertest": "^6.0.2", @@ -2540,9 +2541,9 @@ } }, "node_modules/@nestjs/swagger": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.1.0.tgz", - "integrity": "sha512-+GQ+q1ASTBvGi0DYHukWi8NVVVLszedwLLqHdLRnJh8rjokt8YTDb7roImvT/YMmYgPvaWBv/4JYdZH4FueLPQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.0.tgz", + "integrity": "sha512-5wolt8GmpNcrQv34tIPUtPoV1EeFbCetm40Ij3+M0FNNnf2RJ3FyWfuQvI8SBlcJyfaounYVTKzKHreFXsUyOg==", "license": "MIT", "dependencies": { "@microsoft/tsdoc": "0.15.1", @@ -2550,7 +2551,7 @@ "js-yaml": "4.1.0", "lodash": "4.17.21", "path-to-regexp": "8.2.0", - "swagger-ui-dist": "5.20.1" + "swagger-ui-dist": "5.21.0" }, "peerDependencies": { "@fastify/static": "^8.0.0", @@ -3323,6 +3324,16 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", + "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "22.13.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz", @@ -10835,9 +10846,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.20.1.tgz", - "integrity": "sha512-qBPCis2w8nP4US7SvUxdJD3OwKcqiWeZmjN2VWhq2v+ESZEXOP/7n4DeiOiiZcGYTKMHAHUUrroHaTsjUWTEGw==", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.21.0.tgz", + "integrity": "sha512-E0K3AB6HvQd8yQNSMR7eE5bk+323AUxjtCz/4ZNKiahOlPhPJxqn3UPIGs00cyY/dhrTDJ61L7C/a8u6zhGrZg==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" diff --git a/package.json b/package.json index 8c10e20..9d5c151 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@nestjs/mapped-types": "*", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", - "@nestjs/swagger": "^11.1.0", + "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.0", "@types/bcrypt": "^5.0.2", "@types/bcryptjs": "^2.4.6", @@ -55,6 +55,7 @@ "@swc/core": "^1.10.7", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", + "@types/multer": "^1.4.13", "@types/node": "^22.10.7", "@types/qrcode": "^1.5.5", "@types/supertest": "^6.0.2", diff --git a/src/administrador/administrador.controller.ts b/src/administrador/administrador.controller.ts index 0f6e321..bffcfe1 100644 --- a/src/administrador/administrador.controller.ts +++ b/src/administrador/administrador.controller.ts @@ -1,72 +1,87 @@ -import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; import { AdministradorService } from './administrador.service'; -import { Administrador } from './administrador.entity'; +import { Administrador } from './entities/administrador.entity'; import { CreateAdministradorDto } from './dto/create-administrador.dto'; import { UpdateAdministradorDto } from './dto/update.administrador.dto'; import { LoginAdministradorDto } from './dto/login-administrador.dto'; import { ChangePasswordDto } from './dto/change-password.dto'; -import { ApiBearerAuth } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { AdministradorApiDocumentation } from './administrador.documentation'; -@Controller('administrador') -@ApiBearerAuth() @AdministradorApiDocumentation.ApiController +@ApiBearerAuth() +@Controller('administrador') export class AdministradorController { + constructor(private administradorService: AdministradorService) {} - constructor(private administradorService: AdministradorService) {} + @Get() + @UseGuards(JwtAuthGuard) + @AdministradorApiDocumentation.ApiGetAll + getAdministradores(): Promise { + return this.administradorService.getAdministradores(); + } - @Get() - @UseGuards(JwtAuthGuard) - @AdministradorApiDocumentation.ApiGetAll - getAdministradores(): Promise { - return this.administradorService.getAdministradores(); - } + @Get(':id') + @UseGuards(JwtAuthGuard) + @AdministradorApiDocumentation.ApiGetOne + getAdministrador(@Param('id', ParseIntPipe) id: number) { + return this.administradorService.getAdministrador(id); + } - @Get(':id') - @UseGuards(JwtAuthGuard) - @AdministradorApiDocumentation.ApiGetOne - getAdministrador(@Param('id', ParseIntPipe) id: number) { - return this.administradorService.getAdministrador(id); - } + @Post() + @AdministradorApiDocumentation.ApiCreate + createAdministrador(@Body() newAdministrador: CreateAdministradorDto) { + return this.administradorService.createAdministrador(newAdministrador); + } - @Post() - @AdministradorApiDocumentation.ApiCreate - createAdministrador(@Body() newAdministrador: CreateAdministradorDto) { - return this.administradorService.createAdministrador(newAdministrador); - } + @Post('login') + @AdministradorApiDocumentation.ApiLogin + login(@Body() loginData: LoginAdministradorDto) { + return this.administradorService.login( + loginData.correo, + loginData.password, + ); + } - @Post('login') - @AdministradorApiDocumentation.ApiLogin - login(@Body() loginData: LoginAdministradorDto) { - return this.administradorService.login(loginData.correo, loginData.password); - } + @Patch(':id/change-password') + @UseGuards(JwtAuthGuard) + @AdministradorApiDocumentation.ApiChangePassword + changePassword( + @Param('id', ParseIntPipe) id: number, + @Body() changePasswordDto: ChangePasswordDto, + ) { + return this.administradorService.changePassword( + id, + changePasswordDto.currentPassword, + changePasswordDto.newPassword, + ); + } - @Patch(':id/change-password') - @UseGuards(JwtAuthGuard) - @AdministradorApiDocumentation.ApiChangePassword - changePassword( - @Param('id', ParseIntPipe) id: number, - @Body() changePasswordDto: ChangePasswordDto - ) { - return this.administradorService.changePassword( - id, - changePasswordDto.currentPassword, - changePasswordDto.newPassword - ); - } + @Delete(':id') + @UseGuards(JwtAuthGuard) + @AdministradorApiDocumentation.ApiRemove + deleteAdministrador(@Param('id', ParseIntPipe) id: number) { + return this.administradorService.deleteAdministrador(id); + } - @Delete(':id') - @UseGuards(JwtAuthGuard) - @AdministradorApiDocumentation.ApiRemove - deleteAdministrador(@Param('id', ParseIntPipe) id: number) { - return this.administradorService.deleteAdministrador(id); - } - - @Patch(':id') - @UseGuards(JwtAuthGuard) - @AdministradorApiDocumentation.ApiUpdate - updateAdministrador(@Param('id', ParseIntPipe) id: number, @Body() administrador: UpdateAdministradorDto) { - return this.administradorService.updateAdministrador(id, administrador); - } + @Patch(':id') + @UseGuards(JwtAuthGuard) + @AdministradorApiDocumentation.ApiUpdate + updateAdministrador( + @Param('id', ParseIntPipe) id: number, + @Body() administrador: UpdateAdministradorDto, + ) { + return this.administradorService.updateAdministrador(id, administrador); + } } diff --git a/src/administrador/administrador.documentation.ts b/src/administrador/administrador.documentation.ts index 063bad1..207ade5 100644 --- a/src/administrador/administrador.documentation.ts +++ b/src/administrador/administrador.documentation.ts @@ -1,15 +1,24 @@ -import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { applyDecorators } from '@nestjs/common'; export class AdministradorApiDocumentation { // Decorador para toda la clase del controlador - static ApiController = ApiTags('Administradores'); - + static ApiController = applyDecorators( + ApiTags('Administrador'), + ); // Documentación para crear un administrador static ApiCreate = applyDecorators( ApiOperation({ summary: 'Registrar un nuevo administrador', - description: 'Crea un nuevo administrador con correo, contraseña y tipo de administrador' + description: + 'Crea un nuevo administrador con correo, contraseña y tipo de administrador', }), ApiBody({ description: 'Datos del administrador a registrar', @@ -17,47 +26,51 @@ export class AdministradorApiDocumentation { type: 'object', required: ['correo', 'password', 'id_tipo_user'], properties: { - correo: { - type: 'string', + correo: { + type: 'string', format: 'email', example: 'admin@ejemplo.com', - description: 'Correo electrónico del administrador' + description: 'Correo electrónico del administrador', }, - password: { - type: 'string', + password: { + type: 'string', format: 'password', - example: 'Password123', - description: 'Contraseña del administrador (mínimo 6 caracteres)' + example: 'password', + description: 'Contraseña del administrador (mínimo 6 caracteres)', }, - id_tipo_user: { - type: 'integer', + id_tipo_user: { + type: 'integer', example: 1, - description: 'ID del tipo de usuario administrador' - } - } - } + description: 'ID del tipo de usuario administrador', + }, + }, + }, }), - ApiResponse({ - status: 201, + ApiResponse({ + status: 201, description: 'Administrador registrado exitosamente', schema: { type: 'object', properties: { id_admnistrador: { type: 'number', example: 1 }, correo: { type: 'string', example: 'admin@ejemplo.com' }, - id_tipo_user: { type: 'number', example: 1 } - } - } + id_tipo_user: { type: 'number', example: 1 }, + }, + }, }), - ApiResponse({ status: 400, description: 'Datos del administrador inválidos' }), - ApiResponse({ status: 409, description: 'El correo ya está registrado' }) + ApiResponse({ + status: 400, + description: 'Datos del administrador inválidos', + }), + ApiResponse({ status: 409, description: 'El correo ya está registrado' }), ); // Documentación para iniciar sesión static ApiLogin = applyDecorators( ApiOperation({ summary: 'Iniciar sesión como administrador', - description: 'Autentica un administrador con correo y contraseña, y devuelve un token JWT' + description: + 'Autentica un administrador con correo y contraseña, y devuelve un token JWT', }), ApiBody({ description: 'Credenciales de inicio de sesión', @@ -65,58 +78,59 @@ export class AdministradorApiDocumentation { type: 'object', required: ['correo', 'password'], properties: { - correo: { - type: 'string', + correo: { + type: 'string', format: 'email', example: 'admin@ejemplo.com', - description: 'Correo electrónico del administrador' + description: 'Correo electrónico del administrador', }, - password: { - type: 'string', + password: { + type: 'string', format: 'password', - example: 'Password123', - description: 'Contraseña del administrador' - } - } - } + example: 'password', + description: 'Contraseña del administrador', + }, + }, + }, }), - ApiResponse({ - status: 200, + ApiResponse({ + status: 200, description: 'Inicio de sesión exitoso', schema: { type: 'object', properties: { - access_token: { - type: 'string', + access_token: { + type: 'string', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', - description: 'Token JWT para autenticación' + description: 'Token JWT para autenticación', }, - id_administrador: { - type: 'number', + id_administrador: { + type: 'number', example: 1, - description: 'ID del administrador autenticado' + description: 'ID del administrador autenticado', }, - correo: { - type: 'string', + correo: { + type: 'string', example: 'admin@ejemplo.com', - description: 'Correo del administrador autenticado' + description: 'Correo del administrador autenticado', }, - tipo_user: { - type: 'number', + tipo_user: { + type: 'number', example: 1, - description: 'Tipo de usuario del administrador' - } - } - } + description: 'Tipo de usuario del administrador', + }, + }, + }, }), - ApiResponse({ status: 401, description: 'Credenciales inválidas' }) + ApiResponse({ status: 401, description: 'Credenciales inválidas' }), ); // Documentación para obtener todos los administradores static ApiGetAll = applyDecorators( ApiOperation({ summary: 'Obtener todos los administradores', - description: 'Retorna una lista de todos los administradores registrados (requiere autenticación)' + description: + 'Retorna una lista de todos los administradores registrados (requiere autenticación)', }), ApiResponse({ status: 200, @@ -135,28 +149,32 @@ export class AdministradorApiDocumentation { type: 'object', properties: { id_tipo_user: { type: 'number', example: 1 }, - tipo_user: { type: 'string', example: 'Administrador General' } - } - } - } - } - } - } + tipo_user: { + type: 'string', + example: 'Administrador General', + }, + }, + }, + }, + }, + }, + }, }), - ApiResponse({ status: 401, description: 'No autorizado' }) + ApiResponse({ status: 401, description: 'No autorizado' }), ); // Documentación para obtener un administrador por ID static ApiGetOne = applyDecorators( ApiOperation({ summary: 'Obtener un administrador por ID', - description: 'Retorna los datos de un administrador específico según su ID (requiere autenticación)' + description: + 'Retorna los datos de un administrador específico según su ID (requiere autenticación)', }), ApiParam({ name: 'id', description: 'ID del administrador', type: 'number', - example: 1 + example: 1, }), ApiResponse({ status: 200, @@ -173,53 +191,55 @@ export class AdministradorApiDocumentation { type: 'object', properties: { id_tipo_user: { type: 'number', example: 1 }, - tipo_user: { type: 'string', example: 'Administrador General' } - } - } - } - } - } + tipo_user: { type: 'string', example: 'Administrador General' }, + }, + }, + }, + }, + }, }), ApiResponse({ status: 404, description: 'Administrador no encontrado' }), - ApiResponse({ status: 401, description: 'No autorizado' }) + ApiResponse({ status: 401, description: 'No autorizado' }), ); // Documentación para actualizar un administrador static ApiUpdate = applyDecorators( ApiOperation({ summary: 'Actualizar datos de un administrador', - description: 'Actualiza la información de un administrador existente (requiere autenticación)' + description: + 'Actualiza la información de un administrador existente (requiere autenticación)', }), ApiParam({ name: 'id', description: 'ID del administrador a actualizar', type: 'number', - example: 1 + example: 1, }), ApiBody({ description: 'Datos a actualizar del administrador', schema: { type: 'object', properties: { - correo: { - type: 'string', + correo: { + type: 'string', format: 'email', example: 'nuevo_admin@ejemplo.com', - description: 'Nuevo correo electrónico del administrador' + description: 'Nuevo correo electrónico del administrador', }, - password: { - type: 'string', + password: { + type: 'string', format: 'password', example: 'NuevaPassword123', - description: 'Nueva contraseña del administrador (mínimo 6 caracteres)' + description: + 'Nueva contraseña del administrador (mínimo 6 caracteres)', }, - id_tipo_user: { - type: 'integer', + id_tipo_user: { + type: 'integer', example: 2, - description: 'Nuevo tipo de usuario administrador' - } - } - } + description: 'Nuevo tipo de usuario administrador', + }, + }, + }, }), ApiResponse({ status: 200, @@ -229,26 +249,30 @@ export class AdministradorApiDocumentation { properties: { id_admnistrador: { type: 'number', example: 1 }, correo: { type: 'string', example: 'nuevo_admin@ejemplo.com' }, - id_tipo_user: { type: 'number', example: 2 } - } - } + id_tipo_user: { type: 'number', example: 2 }, + }, + }, + }), + ApiResponse({ + status: 400, + description: 'Datos de actualización inválidos', }), - ApiResponse({ status: 400, description: 'Datos de actualización inválidos' }), ApiResponse({ status: 404, description: 'Administrador no encontrado' }), - ApiResponse({ status: 401, description: 'No autorizado' }) + ApiResponse({ status: 401, description: 'No autorizado' }), ); // Documentación para cambiar contraseña static ApiChangePassword = applyDecorators( ApiOperation({ summary: 'Cambiar contraseña de un administrador', - description: 'Permite a un administrador cambiar su contraseña verificando primero la contraseña actual (requiere autenticación)' + description: + 'Permite a un administrador cambiar su contraseña verificando primero la contraseña actual (requiere autenticación)', }), ApiParam({ name: 'id', description: 'ID del administrador', type: 'number', - example: 1 + example: 1, }), ApiBody({ description: 'Datos para cambio de contraseña', @@ -256,20 +280,21 @@ export class AdministradorApiDocumentation { type: 'object', required: ['currentPassword', 'newPassword'], properties: { - currentPassword: { - type: 'string', + currentPassword: { + type: 'string', format: 'password', example: 'Password123', - description: 'Contraseña actual del administrador' + description: 'Contraseña actual del administrador', }, - newPassword: { - type: 'string', + newPassword: { + type: 'string', format: 'password', example: 'NuevaPassword123', - description: 'Nueva contraseña del administrador (mínimo 6 caracteres)' - } - } - } + description: + 'Nueva contraseña del administrador (mínimo 6 caracteres)', + }, + }, + }, }), ApiResponse({ status: 200, @@ -277,26 +302,30 @@ export class AdministradorApiDocumentation { schema: { type: 'object', properties: { - message: { type: 'string', example: 'Contraseña actualizada correctamente' } - } - } + message: { + type: 'string', + example: 'Contraseña actualizada correctamente', + }, + }, + }, }), ApiResponse({ status: 400, description: 'Contraseña actual incorrecta' }), ApiResponse({ status: 404, description: 'Administrador no encontrado' }), - ApiResponse({ status: 401, description: 'No autorizado' }) + ApiResponse({ status: 401, description: 'No autorizado' }), ); // Documentación para eliminar un administrador static ApiRemove = applyDecorators( ApiOperation({ summary: 'Eliminar un administrador', - description: 'Elimina permanentemente un administrador por su ID (requiere autenticación)' + description: + 'Elimina permanentemente un administrador por su ID (requiere autenticación)', }), ApiParam({ name: 'id', description: 'ID del administrador a eliminar', type: 'number', - example: 1 + example: 1, }), ApiResponse({ status: 200, @@ -304,11 +333,11 @@ export class AdministradorApiDocumentation { schema: { type: 'object', properties: { - affected: { type: 'number', example: 1 } - } - } + affected: { type: 'number', example: 1 }, + }, + }, }), ApiResponse({ status: 404, description: 'Administrador no encontrado' }), - ApiResponse({ status: 401, description: 'No autorizado' }) + ApiResponse({ status: 401, description: 'No autorizado' }), ); -} \ No newline at end of file +} diff --git a/src/administrador/administrador.entity.ts b/src/administrador/administrador.entity.ts deleted file mode 100644 index f414872..0000000 --- a/src/administrador/administrador.entity.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { TipoUser } from "src/tipo_user/tipo_user.entity"; -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; - -@Entity() -export class Administrador { - @PrimaryGeneratedColumn() - id_admnistrador: number - - @Column({ unique: true }) - correo: string - - @Column() - password: string - - /* - @ManyToOne(() => TipoUser, (tipoUser) => tipoUser.administradores) - @JoinColumn({ name: "id_tipo_user" }) - tipoUser: TipoUser; - */ - //Relacion con tipo_user - @Column() - id_tipo_user: number - - @ManyToOne(() => TipoUser, (tipoUser) => tipoUser.administrador) - tipoUser: TipoUser[] - - /* - @OneToMany(() => Evento, (evento) => evento.administrador) - eventos: Evento[]; - - @OneToMany(() => Asistencia, (asistencia) => asistencia.administrador) - asistencias: Asistencia[]; - */ -} \ No newline at end of file diff --git a/src/administrador/administrador.module.ts b/src/administrador/administrador.module.ts index e2a9b80..7eaa84d 100644 --- a/src/administrador/administrador.module.ts +++ b/src/administrador/administrador.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { AdministradorService } from './administrador.service'; import { AdministradorController } from './administrador.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Administrador } from './administrador.entity'; +import { Administrador } from './entities/administrador.entity'; import { AuthModule } from '../auth/auth.module'; @Module({ diff --git a/src/administrador/administrador.service.ts b/src/administrador/administrador.service.ts index db067f7..818188e 100644 --- a/src/administrador/administrador.service.ts +++ b/src/administrador/administrador.service.ts @@ -1,6 +1,6 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Administrador } from './administrador.entity'; +import { Administrador } from './entities/administrador.entity'; import { Repository } from 'typeorm'; import { CreateAdministradorDto } from './dto/create-administrador.dto'; import { UpdateAdministradorDto } from './dto/update.administrador.dto'; @@ -9,163 +9,204 @@ import { JwtService } from '@nestjs/jwt'; @Injectable() export class AdministradorService { - - constructor( - @InjectRepository(Administrador) private administradorRepository: Repository, - private jwtService: JwtService - ) {} + constructor( + @InjectRepository(Administrador) + private administradorRepository: Repository, + private jwtService: JwtService, + ) {} - async createAdministrador(administrador: CreateAdministradorDto) { - // Verificar si ya existe un administrador con el mismo correo - const administradorFound = await this.administradorRepository.findOne({ - where: { - correo: administrador.correo - } - }); + async createAdministrador(administrador: CreateAdministradorDto) { + // Verificar si ya existe un administrador con el mismo correo + const administradorFound = await this.administradorRepository.findOne({ + where: { + correo: administrador.correo, + }, + }); - if (administradorFound) { - throw new HttpException('El correo ya está registrado', HttpStatus.CONFLICT); - } - - // Encriptar la contraseña - const hashedPassword = await this.hashPassword(administrador.password); - - // Crear y guardar el nuevo administrador - const newAdministrador = this.administradorRepository.create({ - correo: administrador.correo, - password: hashedPassword, - id_tipo_user: administrador.id_tipo_user - }); - - const savedAdministrador = await this.administradorRepository.save(newAdministrador); - - // Excluir la contraseña de la respuesta - const { password, ...result } = savedAdministrador; - return result; + if (administradorFound) { + throw new HttpException( + 'El correo ya está registrado', + HttpStatus.CONFLICT, + ); } - async login(correo: string, password: string) { - // Buscar administrador por correo - const administrador = await this.administradorRepository.findOne({ - where: { correo }, - relations: ['tipoUser'] - }); + // Encriptar la contraseña + const hashedPassword = await this.hashPassword(administrador.password); - if (!administrador) { - throw new HttpException('Credenciales inválidas', HttpStatus.UNAUTHORIZED); - } + // Crear y guardar el nuevo administrador + const newAdministrador = this.administradorRepository.create({ + correo: administrador.correo, + password: hashedPassword, + id_tipo_user: administrador.id_tipo_user, + }); - // Verificar contraseña - const isPasswordValid = await bcrypt.compare(password, administrador.password); - if (!isPasswordValid) { - throw new HttpException('Credenciales inválidas', HttpStatus.UNAUTHORIZED); - } + const savedAdministrador = + await this.administradorRepository.save(newAdministrador); - // Generar token JWT - const payload = { - sub: administrador.id_admnistrador, - correo: administrador.correo, - id_tipo_user: administrador.id_tipo_user - }; - - return { - access_token: this.jwtService.sign(payload), - id_administrador: administrador.id_admnistrador, - correo: administrador.correo, - tipo_user: administrador.id_tipo_user - }; + // Excluir la contraseña de la respuesta + const { password, ...result } = savedAdministrador; + return result; + } + + async login(correo: string, password: string) { + // Buscar administrador por correo + const administrador = await this.administradorRepository.findOne({ + where: { correo }, + relations: ['tipoUser'], + }); + + if (!administrador) { + throw new HttpException( + 'Credenciales inválidas', + HttpStatus.UNAUTHORIZED, + ); } - async changePassword(id_admnistrador: number, currentPassword: string, newPassword: string) { - // Buscar administrador - const administrador = await this.administradorRepository.findOne({ - where: { id_admnistrador } - }); - - if (!administrador) { - throw new HttpException('Administrador no encontrado', HttpStatus.NOT_FOUND); - } - - // Verificar contraseña actual - const isPasswordValid = await bcrypt.compare(currentPassword, administrador.password); - if (!isPasswordValid) { - throw new HttpException('Contraseña actual incorrecta', HttpStatus.BAD_REQUEST); - } - - // Hashear y actualizar nueva contraseña - administrador.password = await this.hashPassword(newPassword); - await this.administradorRepository.save(administrador); - - return { message: 'Contraseña actualizada correctamente' }; + // Verificar contraseña + const isPasswordValid = await bcrypt.compare( + password, + administrador.password, + ); + if (!isPasswordValid) { + throw new HttpException( + 'Credenciales inválidas', + HttpStatus.UNAUTHORIZED, + ); } - private async hashPassword(password: string): Promise { - const salt = await bcrypt.genSalt(); - return bcrypt.hash(password, salt); + // Generar token JWT + const payload = { + sub: administrador.id_admnistrador, + correo: administrador.correo, + id_tipo_user: administrador.id_tipo_user, + }; + + return { + token: this.jwtService.sign(payload), + }; + } + + async changePassword( + id_admnistrador: number, + currentPassword: string, + newPassword: string, + ) { + // Buscar administrador + const administrador = await this.administradorRepository.findOne({ + where: { id_admnistrador }, + }); + + if (!administrador) { + throw new HttpException( + 'Administrador no encontrado', + HttpStatus.NOT_FOUND, + ); } - getAdministradores() { - return this.administradorRepository.find({ - relations: ['tipoUser'], - select: { - id_admnistrador: true, - correo: true, - id_tipo_user: true - } - }); + // Verificar contraseña actual + const isPasswordValid = await bcrypt.compare( + currentPassword, + administrador.password, + ); + if (!isPasswordValid) { + throw new HttpException( + 'Contraseña actual incorrecta', + HttpStatus.BAD_REQUEST, + ); } - async getAdministrador(id_admnistrador: number) { - const administradorFound = await this.administradorRepository.findOne({ - where: { - id_admnistrador - }, - relations: ['tipoUser'], - select: { - id_admnistrador: true, - correo: true, - id_tipo_user: true - } - }); + // Hashear y actualizar nueva contraseña + administrador.password = await this.hashPassword(newPassword); + await this.administradorRepository.save(administrador); - if (!administradorFound) { - throw new HttpException('Administrador no encontrado', HttpStatus.NOT_FOUND); - } + return { message: 'Contraseña actualizada correctamente' }; + } - return administradorFound; + private async hashPassword(password: string): Promise { + const salt = await bcrypt.genSalt(); + return bcrypt.hash(password, salt); + } + + getAdministradores() { + return this.administradorRepository.find({ + relations: ['tipoUser'], + select: { + id_admnistrador: true, + correo: true, + id_tipo_user: true, + }, + }); + } + + async getAdministrador(id_admnistrador: number) { + const administradorFound = await this.administradorRepository.findOne({ + where: { + id_admnistrador, + }, + relations: ['tipoUser'], + select: { + id_admnistrador: true, + correo: true, + id_tipo_user: true, + }, + }); + + if (!administradorFound) { + throw new HttpException( + 'Administrador no encontrado', + HttpStatus.NOT_FOUND, + ); } - async deleteAdministrador(id_admnistrador: number) { - const result = await this.administradorRepository.delete({ id_admnistrador }); + return administradorFound; + } - if (result.affected === 0) { - throw new HttpException('Administrador no encontrado', HttpStatus.NOT_FOUND); - } + async deleteAdministrador(id_admnistrador: number) { + const result = await this.administradorRepository.delete({ + id_admnistrador, + }); - return result; + if (result.affected === 0) { + throw new HttpException( + 'Administrador no encontrado', + HttpStatus.NOT_FOUND, + ); } - async updateAdministrador(id_admnistrador: number, administrador: UpdateAdministradorDto) { - const administradorFound = await this.administradorRepository.findOne({ - where: { - id_admnistrador - } - }); + return result; + } - if (!administradorFound) { - throw new HttpException('Administrador no encontrado', HttpStatus.NOT_FOUND); - } + async updateAdministrador( + id_admnistrador: number, + administrador: UpdateAdministradorDto, + ) { + const administradorFound = await this.administradorRepository.findOne({ + where: { + id_admnistrador, + }, + }); - // Si se incluye una nueva contraseña, hashearla - if (administrador.password) { - administrador.password = await this.hashPassword(administrador.password); - } - - const updateAdministrador = Object.assign(administradorFound, administrador); - const savedAdministrador = await this.administradorRepository.save(updateAdministrador); - - // Excluir la contraseña de la respuesta - const { password, ...result } = savedAdministrador; - return result; + if (!administradorFound) { + throw new HttpException( + 'Administrador no encontrado', + HttpStatus.NOT_FOUND, + ); } + + // Si se incluye una nueva contraseña, hashearla + if (administrador.password) { + administrador.password = await this.hashPassword(administrador.password); + } + + const updateAdministrador = Object.assign( + administradorFound, + administrador, + ); + const savedAdministrador = + await this.administradorRepository.save(updateAdministrador); + + // Excluir la contraseña de la respuesta + const { password, ...result } = savedAdministrador; + return result; + } } diff --git a/src/administrador/entities/administrador.entity.ts b/src/administrador/entities/administrador.entity.ts new file mode 100644 index 0000000..eb639a0 --- /dev/null +++ b/src/administrador/entities/administrador.entity.ts @@ -0,0 +1,20 @@ +import { TipoUser } from 'src/tipo_user/entities/tipo_user.entity'; +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('administrador') +export class Administrador { + @PrimaryGeneratedColumn() + id_admnistrador: number; + + @Column({ unique: true }) + correo: string; + + @Column() + password: string; + + @Column() + id_tipo_user: number; + + @ManyToOne(() => TipoUser, (tipoUser) => tipoUser.administrador) + tipoUser: TipoUser[]; +} diff --git a/src/alumnos/alumnos.module.ts b/src/alumnos/alumnos.module.ts index 9a4935d..ff25a76 100644 --- a/src/alumnos/alumnos.module.ts +++ b/src/alumnos/alumnos.module.ts @@ -3,49 +3,9 @@ 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('ALUMNOS_DB_HOST'), - port: Number(configService.get('ALUMNOS_DB_PORT')), - username: configService.get('ALUMNOS_DB_USERNAME'), - password: configService.get('ALUMNOS_DB_PASSWORD'), - database: configService.get('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) │ │ - - }), */ - ], + imports: [TypeOrmModule.forFeature([RegistroAlumno], 'alumnosConnection')], controllers: [AlumnosController], providers: [AlumnosService], exports: [AlumnosService], diff --git a/src/app.module.ts b/src/app.module.ts index 42dc231..7496496 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,6 +2,11 @@ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { createAlumnosDbConfig, createMainDbConfig } from './db/typeorm.config'; +import { AdministradorModule } from './administrador/administrador.module'; +import { AuthModule } from './auth/auth.module'; import { CuestionarioModule } from './cuestionario/cuestionario.module'; import { TipoCuestionarioModule } from './tipo_cuestionario/tipo_cuestionario.module'; import { CuestionarioSeccionModule } from './cuestionario_seccion/cuestionario_seccion.module'; @@ -14,80 +19,53 @@ import { PreguntaOpcionModule } from './pregunta_opcion/pregunta_opcion.module'; import { RespuestaParticipanteCerradaModule } from './respuesta_participante_cerrada/respuesta_participante_cerrada.module'; import { CuestionarioRespondidoModule } from './cuestionario_respondido/cuestionario_respondido.module'; import { RespuestaParticipanteAbiertaModule } from './respuesta_participante_abierta/respuesta_participante_abierta.module'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { ConfigModule } from '@nestjs/config'; import { EventoModule } from './evento/evento.module'; import { TipoUserModule } from './tipo_user/tipo_user.module'; import { ParticipanteModule } from './participante/participante.module'; 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'; -import { AuthModule } from './auth/auth.module'; - @Module({ imports: [ - ConfigModule.forRoot({ - isGlobal: true, + ConfigModule.forRoot({ isGlobal: true }), + + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + useFactory: createMainDbConfig, + inject: [ConfigService], }), - TypeOrmModule.forRoot({ - - type: 'mysql', - host: process.env.db_host, //process.env.db_host - username: process.env.db_username, - database: process.env.db_database, - password: process.env.db_password, - port: Number(process.env.db_port), - - synchronize: false, - dropSchema: false, // elimina la base de datos - - // logging: true, // Habilita los logs para depuración - autoLoadEntities: true, // Carga automáticamente las entidades - - //esta es mi base de datos local - - /* - type: 'mysql', - host: 'localhost', - port: 3306, //3306 - username: 'root', - password: 'admin', //admin - database: 'cidwa', //nestdb - */ - // entities: [__dirname + '/**/*.entity{.ts,.js}'], - // synchronize: true, - - - //extra - retryAttempts: 10, // Intentos para reconectar - retryDelay: 3000, // Tiempo entre reintentos + TypeOrmModule.forRootAsync({ + imports: [ConfigModule], + name: 'alumnosConnection', + useFactory: createAlumnosDbConfig, + inject: [ConfigService], }), - AuthModule, - CuestionarioModule, - TipoCuestionarioModule, - CuestionarioSeccionModule, - SeccionModule, - SeccionPreguntaModule, - PreguntaModule, - TipoPreguntaModule, - OpcionModule, PreguntaOpcionModule, - RespuestaParticipanteCerradaModule, - CuestionarioRespondidoModule, - RespuestaParticipanteAbiertaModule, - EventoModule, - TipoUserModule, - ParticipanteModule, - QrModule, AdministradorModule, - AsistenciaModule, - ParticipanteEventoModule, - ValidacionesModule, AlumnosModule, + AsistenciaModule, + AuthModule, + CuestionarioModule, + CuestionarioRespondidoModule, + CuestionarioSeccionModule, + EventoModule, + OpcionModule, + ParticipanteEventoModule, + ParticipanteModule, + PreguntaModule, + PreguntaOpcionModule, + RespuestaParticipanteAbiertaModule, + RespuestaParticipanteCerradaModule, + SeccionModule, + SeccionPreguntaModule, + TipoCuestionarioModule, + TipoPreguntaModule, + TipoUserModule, + ValidacionesModule, + QrModule, ], controllers: [AppController], diff --git a/src/asistencia/asistencia.controller.ts b/src/asistencia/asistencia.controller.ts index 1107ab8..94814ff 100644 --- a/src/asistencia/asistencia.controller.ts +++ b/src/asistencia/asistencia.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common'; import { AsistenciaService } from './asistencia.service'; -import { Asistencia } from './asistencia.entity'; +import { Asistencia } from './entities/asistencia.entity'; import { CreateAsistenciaDto } from './dto/create-asistencia.dto'; import { UpdateAsistenciaDto } from './dto/update.asistencia.dto'; diff --git a/src/asistencia/asistencia.entity.ts b/src/asistencia/asistencia.entity.ts deleted file mode 100644 index 83c5dc5..0000000 --- a/src/asistencia/asistencia.entity.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; - -@Entity() -export class Asistencia { - @PrimaryGeneratedColumn() - id_asistecia: number - - //({type: 'datetime', default: () => 'CURRENT_TIMESTAMP' } - @Column({type: 'datetime', default: () => 'CURRENT_TIMESTAMP'}) - fecha_asistencia: Date - - @Column() - metodo: boolean - - @Column() - estado: boolean - - //relaciones con las otras tablas - @Column() - id_participante: number - - @Column() - id_evento: number - - @Column() - id_administrador: number - - /* - @ManyToOne(() => Administrador, (admin) => admin.asistencias) - @JoinColumn({ name: "id_administrador" }) - administrador: Administrador; - - @ManyToOne(() => Evento, (evento) => evento.asistencias) - @JoinColumn({ name: "id_evento" }) - evento: Evento; - - @ManyToOne(() => Participante, (participante) => participante.asistencias) - @JoinColumn({ name: "id_participante" }) - participante: Participante; - */ -} \ No newline at end of file diff --git a/src/asistencia/asistencia.module.ts b/src/asistencia/asistencia.module.ts index f16c339..8adbaea 100644 --- a/src/asistencia/asistencia.module.ts +++ b/src/asistencia/asistencia.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { AsistenciaService } from './asistencia.service'; import { AsistenciaController } from './asistencia.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Asistencia } from './asistencia.entity'; +import { Asistencia } from './entities/asistencia.entity'; @Module({ imports: [TypeOrmModule.forFeature([Asistencia])], diff --git a/src/asistencia/asistencia.service.ts b/src/asistencia/asistencia.service.ts index 1732a79..277e97d 100644 --- a/src/asistencia/asistencia.service.ts +++ b/src/asistencia/asistencia.service.ts @@ -1,6 +1,6 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Asistencia } from './asistencia.entity'; +import { Asistencia } from './entities/asistencia.entity'; import { Repository } from 'typeorm'; import { CreateAsistenciaDto } from './dto/create-asistencia.dto'; import { UpdateAsistenciaDto } from './dto/update.asistencia.dto'; diff --git a/src/asistencia/entities/asistencia.entity.ts b/src/asistencia/entities/asistencia.entity.ts new file mode 100644 index 0000000..b111314 --- /dev/null +++ b/src/asistencia/entities/asistencia.entity.ts @@ -0,0 +1,25 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('asistencia') +export class Asistencia { + @PrimaryGeneratedColumn() + id_asistecia: number; + + @Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + fecha_asistencia: Date; + + @Column() + metodo: boolean; + + @Column() + estado: boolean; + + @Column() + id_participante: number; + + @Column() + id_evento: number; + + @Column() + id_administrador: number; +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index dc5a2a9..4fdcaab 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -2,17 +2,23 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Administrador } from '../administrador/administrador.entity'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { Administrador } from '../administrador/entities/administrador.entity'; import { JwtStrategy } from './jwt.strategy'; @Module({ imports: [ + ConfigModule, PassportModule.register({ defaultStrategy: 'jwt' }), - JwtModule.register({ - secret: process.env.JWT_SECRET || 'tu_clave_secreta', - signOptions: { - expiresIn: '24h', // Tokens expiran en 24 horas - }, + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET', 'tu_clave_secreta'), + signOptions: { + expiresIn: '24h', + }, + }), }), TypeOrmModule.forFeature([Administrador]), ], diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index f830986..20fb513 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -3,7 +3,7 @@ import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; -import { Administrador } from '../administrador/administrador.entity'; +import { Administrador } from '../administrador/entities/administrador.entity'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { diff --git a/src/cuestionario/cuestionario.controller.spec.ts b/src/cuestionario/cuestionario.controller.spec.ts deleted file mode 100644 index cff08e4..0000000 --- a/src/cuestionario/cuestionario.controller.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CuestionarioController } from './cuestionario.controller'; -import { CuestionarioService } from './cuestionario.service'; - -describe('CuestionarioController', () => { - let controller: CuestionarioController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [CuestionarioController], - providers: [CuestionarioService], - }).compile(); - - controller = module.get(CuestionarioController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/cuestionario/cuestionario.controller.ts b/src/cuestionario/cuestionario.controller.ts index f62f04f..a88662a 100644 --- a/src/cuestionario/cuestionario.controller.ts +++ b/src/cuestionario/cuestionario.controller.ts @@ -1,9 +1,25 @@ -import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseInterceptors, + ParseIntPipe, + UploadedFile, +} from '@nestjs/common'; import { CuestionarioService } from './cuestionario.service'; -import { CreateCuestionarioDto } from './dto/create-cuestionario.dto'; +import { + CreateCuestionarioDto, + CreateCuestionarioEventoDto, +} from './dto/create-cuestionario.dto'; import { UpdateCuestionarioDto } from './dto/update-cuestionario.dto'; -import { CuestionarioApiDocumentation } from './cuestionario.documentation'; -import { ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +import { extname } from 'path'; +import { CuestionarioApiDocumentation } from './docs/cuestionario.documentation'; @Controller('cuestionario') @CuestionarioApiDocumentation.ApiController @@ -16,6 +32,50 @@ export class CuestionarioController { return this.cuestionarioService.create(createCuestionarioDto); } + @Post('evento') + @CuestionarioApiDocumentation.ApiCreateWithEvento + createCuestionarioEvento( + @Body() createCuestionarioDto: CreateCuestionarioEventoDto, + ) { + return this.cuestionarioService.createCuestionarioEvento( + createCuestionarioDto, + ); + } + + @Post(':id/banner') + @UseInterceptors( + FileInterceptor('banner', { + storage: diskStorage({ + destination: './public/banners', // Asegúrate de que esta carpeta exista + filename: (req, file, cb) => { + const uniqueSuffix = + Date.now() + '-' + Math.round(Math.random() * 1e9); + const ext = extname(file.originalname); + cb(null, `banner-${uniqueSuffix}${ext}`); + }, + }), + fileFilter: (req, file, cb) => { + const allowedTypes = /jpeg|jpg|png|webp/; + const ext = extname(file.originalname).toLowerCase(); + if (allowedTypes.test(ext)) { + cb(null, true); + } else { + cb(new Error('Tipo de archivo no permitido'), false); + } + }, + }), + ) + async subirBannerEvento( + @Param('id', ParseIntPipe) id: number, + @UploadedFile() file: Express.Multer.File, + ) { + if (!file) { + throw new Error('No se ha recibido un archivo válido'); + } + + return this.cuestionarioService.asociarBanner(id, file.filename); + } + @Get() @CuestionarioApiDocumentation.ApiGetAll findAll() { @@ -27,7 +87,7 @@ export class CuestionarioController { findOne(@Param('id') id: string) { return this.cuestionarioService.findOne(+id); } - + @Get(':id/formulario') @CuestionarioApiDocumentation.ApiGetFormulario findFormulario(@Param('id') id: string) { @@ -36,7 +96,10 @@ export class CuestionarioController { @Patch(':id') @CuestionarioApiDocumentation.ApiUpdate - update(@Param('id') id: string, @Body() updateCuestionarioDto: UpdateCuestionarioDto) { + update( + @Param('id') id: string, + @Body() updateCuestionarioDto: UpdateCuestionarioDto, + ) { return this.cuestionarioService.update(+id, updateCuestionarioDto); } diff --git a/src/cuestionario/cuestionario.module.ts b/src/cuestionario/cuestionario.module.ts index b759268..019c0ae 100644 --- a/src/cuestionario/cuestionario.module.ts +++ b/src/cuestionario/cuestionario.module.ts @@ -11,7 +11,7 @@ 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 { Evento } from '../evento/entities/evento.entity'; import { EventoService } from '../evento/evento.service'; @Module({ diff --git a/src/cuestionario/cuestionario.service.spec.ts b/src/cuestionario/cuestionario.service.spec.ts deleted file mode 100644 index e88a9db..0000000 --- a/src/cuestionario/cuestionario.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CuestionarioService } from './cuestionario.service'; - -describe('CuestionarioService', () => { - let service: CuestionarioService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [CuestionarioService], - }).compile(); - - service = module.get(CuestionarioService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index f3e05ad..01aad4f 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -1,7 +1,10 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, In } from 'typeorm'; -import { CreateCuestionarioDto } from './dto/create-cuestionario.dto'; +import { + CreateCuestionarioDto, + CreateCuestionarioEventoDto, +} from './dto/create-cuestionario.dto'; import { UpdateCuestionarioDto } from './dto/update-cuestionario.dto'; import { Cuestionario } from './entities/cuestionario.entity'; import { Seccion } from '../seccion/entities/seccion.entity'; @@ -10,9 +13,11 @@ import { Pregunta } from '../pregunta/entities/pregunta.entity'; import { SeccionPregunta } from '../seccion_pregunta/entities/seccion_pregunta.entity'; import { Opcion } from '../opcion/entities/opcion.entity'; import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity'; -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 { + TipoPregunta, + TipoPreguntaEnum, +} from '../tipo_pregunta/entities/tipo_pregunta.entity'; +import { Evento } from '../evento/entities/evento.entity'; import { EventoService } from '../evento/evento.service'; @Injectable() @@ -37,138 +42,115 @@ export class CuestionarioService { @InjectRepository(Evento) private eventoRepository: Repository, private dataSource: DataSource, - private eventoService: EventoService + private eventoService: EventoService, ) {} async create(createCuestionarioDto: CreateCuestionarioDto) { + // Verificar que el evento exista + await this.eventoService.getEventoOrFail(createCuestionarioDto.id_evento); + const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); 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; cuestionario.descripcion = createCuestionarioDto.descripcion; - cuestionario.fecha_inicio = createCuestionarioDto.fecha_inicio ? new Date(createCuestionarioDto.fecha_inicio) : undefined; - cuestionario.fecha_fin = createCuestionarioDto.fecha_fin ? new Date(createCuestionarioDto.fecha_fin) : undefined; - cuestionario.id_tipo_cuestionario = createCuestionarioDto.id_tipo_cuestionario; - cuestionario.contador_secciones = createCuestionarioDto.secciones?.length || 0; + cuestionario.fecha_inicio = new Date(createCuestionarioDto.fecha_inicio); + cuestionario.fecha_fin = new Date(createCuestionarioDto.fecha_fin); + cuestionario.id_tipo_cuestionario = + createCuestionarioDto.id_tipo_cuestionario; + 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; - } + cuestionario.id_evento = createCuestionarioDto.id_evento; const savedCuestionario = await queryRunner.manager.save(cuestionario); - // 2. Crear secciones y vincularlas al cuestionario - if (createCuestionarioDto.secciones && createCuestionarioDto.secciones.length > 0) { + // 2. Crear secciones y vincularlas + if ( + createCuestionarioDto.secciones && + createCuestionarioDto.secciones?.length > 0 + ) { for (let i = 0; i < createCuestionarioDto.secciones.length; i++) { const seccionDto = createCuestionarioDto.secciones[i]; - - // Crear sección + const seccion = new Seccion(); seccion.titulo = seccionDto.titulo; seccion.descripcion = seccionDto.descripcion; seccion.contador_pregunta = seccionDto.preguntas?.length || 0; - + const savedSeccion = await queryRunner.manager.save(seccion); - - // Vincular sección con cuestionario + const cuestionarioSeccion = new CuestionarioSeccion(); - cuestionarioSeccion.id_cuestionario = savedCuestionario.id_cuestionario; + cuestionarioSeccion.id_cuestionario = + savedCuestionario.id_cuestionario; cuestionarioSeccion.id_seccion = savedSeccion.id_seccion; cuestionarioSeccion.posicion = i + 1; - + await queryRunner.manager.save(cuestionarioSeccion); - - // 3. Crear preguntas y vincularlas a la sección - if (seccionDto.preguntas && seccionDto.preguntas.length > 0) { + + // 3. Preguntas por sección + if (seccionDto.preguntas && seccionDto.preguntas?.length > 0) { for (let j = 0; j < seccionDto.preguntas.length; j++) { const preguntaDto = seccionDto.preguntas[j]; - - // Mapear directamente el tipo de pregunta a su ID correspondiente - let idTipoPregunta = 7; // Valor por defecto (desconocido) - - // 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.`); + + let idTipoPregunta = 7; + + switch (preguntaDto.tipo) { + case TipoPreguntaEnum.AbiertaParrafo: + idTipoPregunta = 1; + break; + case TipoPreguntaEnum.AbiertaRespuestaCorta: + idTipoPregunta = 2; + break; + case TipoPreguntaEnum.Cerrada: + idTipoPregunta = 3; + break; + case TipoPreguntaEnum.Multiple: + idTipoPregunta = 4; + break; + default: + console.warn( + `Tipo de pregunta no reconocido: ${preguntaDto.tipo}. Se asignará como desconocido.`, + ); + idTipoPregunta = 7; + break; } - - const tipoPregunta = { id_tipo: idTipoPregunta, tipo_pregunta: preguntaDto.tipo }; - - // Crear pregunta + const pregunta = new Pregunta(); pregunta.pregunta = preguntaDto.titulo; pregunta.obligatoria = preguntaDto.obligatoria || false; - pregunta.id_tipo_pregunta = tipoPregunta.id_tipo; - pregunta.id_opcion_dependiente = preguntaDto.id_opcion_dependiente || undefined; + pregunta.id_tipo_pregunta = idTipoPregunta; pregunta.contador_opcion = preguntaDto.opciones?.length || 0; pregunta.validacion = preguntaDto.validacion || undefined; - + const savedPregunta = await queryRunner.manager.save(pregunta); - - // Vincular pregunta con sección + const seccionPregunta = new SeccionPregunta(); seccionPregunta.id_seccion = savedSeccion.id_seccion; seccionPregunta.id_pregunta = savedPregunta.id_pregunta; seccionPregunta.posicion = j + 1; - + await queryRunner.manager.save(seccionPregunta); - - // 4. Crear opciones para preguntas de tipo multiple/radio - if (preguntaDto.opciones && preguntaDto.opciones.length > 0) { + + // 4. Crear opciones si las hay + if (preguntaDto.opciones && preguntaDto.opciones?.length > 0) { for (let k = 0; k < preguntaDto.opciones.length; k++) { const opcionDto = preguntaDto.opciones[k]; - - // Crear opción + const opcion = new Opcion(); opcion.opcion = opcionDto.valor; - + const savedOpcion = await queryRunner.manager.save(opcion); - - // Vincular opción con pregunta + const preguntaOpcion = new PreguntaOpcion(); preguntaOpcion.id_pregunta = savedPregunta.id_pregunta; - // Utilizamos la opción como objeto, no como ID preguntaOpcion.opcion = savedOpcion; preguntaOpcion.posicion = k + 1; - + await queryRunner.manager.save(preguntaOpcion); } } @@ -178,7 +160,184 @@ export class CuestionarioService { } await queryRunner.commitTransaction(); - + + return { + success: true, + message: 'Cuestionario creado exitosamente', + cuestionario: savedCuestionario, + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } + + async createCuestionarioEvento( + createCuestionarioDto: CreateCuestionarioEventoDto, + ) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + 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; + cuestionario.descripcion = createCuestionarioDto.descripcion; + cuestionario.fecha_inicio = new Date(createCuestionarioDto.fecha_inicio); + cuestionario.fecha_fin = new Date(createCuestionarioDto.fecha_fin); + cuestionario.id_tipo_cuestionario = + createCuestionarioDto.id_tipo_cuestionario; + 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 + if ( + createCuestionarioDto.secciones && + createCuestionarioDto.secciones.length > 0 + ) { + for (let i = 0; i < createCuestionarioDto.secciones.length; i++) { + const seccionDto = createCuestionarioDto.secciones[i]; + + // Crear sección + const seccion = new Seccion(); + seccion.titulo = seccionDto.titulo; + seccion.descripcion = seccionDto.descripcion; + seccion.contador_pregunta = seccionDto.preguntas?.length || 0; + + const savedSeccion = await queryRunner.manager.save(seccion); + + // Vincular sección con cuestionario + const cuestionarioSeccion = new CuestionarioSeccion(); + cuestionarioSeccion.id_cuestionario = + savedCuestionario.id_cuestionario; + cuestionarioSeccion.id_seccion = savedSeccion.id_seccion; + cuestionarioSeccion.posicion = i + 1; + + await queryRunner.manager.save(cuestionarioSeccion); + + // 3. Crear preguntas y vincularlas a la sección + if (seccionDto.preguntas && seccionDto.preguntas.length > 0) { + for (let j = 0; j < seccionDto.preguntas.length; j++) { + const preguntaDto = seccionDto.preguntas[j]; + + let idTipoPregunta = 7; + + switch (preguntaDto.tipo) { + case TipoPreguntaEnum.AbiertaParrafo: + idTipoPregunta = 1; + break; + case TipoPreguntaEnum.AbiertaRespuestaCorta: + idTipoPregunta = 2; + break; + case TipoPreguntaEnum.Cerrada: + idTipoPregunta = 3; + break; + case TipoPreguntaEnum.Multiple: + idTipoPregunta = 4; + break; + default: + console.warn( + `Tipo de pregunta no reconocido: ${preguntaDto.tipo}. Se asignará como desconocido.`, + ); + idTipoPregunta = 7; + break; + } + + const tipoPregunta = { + id_tipo: idTipoPregunta, + tipo_pregunta: preguntaDto.tipo, + }; + + // Crear pregunta + const pregunta = new Pregunta(); + pregunta.pregunta = preguntaDto.titulo; + pregunta.obligatoria = preguntaDto.obligatoria || false; + pregunta.id_tipo_pregunta = tipoPregunta.id_tipo; + pregunta.contador_opcion = preguntaDto.opciones?.length || 0; + pregunta.validacion = preguntaDto.validacion || undefined; + + const savedPregunta = await queryRunner.manager.save(pregunta); + + // Vincular pregunta con sección + const seccionPregunta = new SeccionPregunta(); + seccionPregunta.id_seccion = savedSeccion.id_seccion; + seccionPregunta.id_pregunta = savedPregunta.id_pregunta; + seccionPregunta.posicion = j + 1; + + await queryRunner.manager.save(seccionPregunta); + + // 4. Crear opciones para preguntas de tipo multiple/radio + if (preguntaDto.opciones && preguntaDto.opciones.length > 0) { + for (let k = 0; k < preguntaDto.opciones.length; k++) { + const opcionDto = preguntaDto.opciones[k]; + + // Crear opción + const opcion = new Opcion(); + opcion.opcion = opcionDto.valor; + + const savedOpcion = await queryRunner.manager.save(opcion); + + // Vincular opción con pregunta + const preguntaOpcion = new PreguntaOpcion(); + preguntaOpcion.id_pregunta = savedPregunta.id_pregunta; + // Utilizamos la opción como objeto, no como ID + preguntaOpcion.opcion = savedOpcion; + preguntaOpcion.posicion = k + 1; + + await queryRunner.manager.save(preguntaOpcion); + } + } + } + } + } + } + + await queryRunner.commitTransaction(); + return { success: true, cuestionario: savedCuestionario, @@ -191,106 +350,146 @@ export class CuestionarioService { } } + async asociarBanner(id: number, banner: string) { + const cuestionario = await this.cuestionarioRepository.findOne({ + where: { id_cuestionario: id }, + }); + + if (!cuestionario) { + throw new NotFoundException(`Cuestionario con ID ${id} no encontrado`); + } + + // Actualizar el banner del cuestionario + cuestionario.banner = banner; + return this.cuestionarioRepository.save(cuestionario); + } + + async getCuestionarioOrFail(id_cuestionario: number): Promise { + const cuestionario = await this.cuestionarioRepository.findOne({ + where: { id_cuestionario: id_cuestionario }, + relations: ['evento'], + }); + + if (!cuestionario) { + throw new NotFoundException( + `Cuestionario con ID ${id_cuestionario} no encontrado`, + ); + } + + return cuestionario; + } + findAll() { return this.cuestionarioRepository.find(); } async findOne(id: number) { - const cuestionario = await this.cuestionarioRepository.findOne({ - where: { id_cuestionario: id } + const cuestionario = await this.cuestionarioRepository.findOne({ + where: { id_cuestionario: id }, }); - + if (!cuestionario) { throw new NotFoundException(`Cuestionario con ID ${id} no encontrado`); } - + return cuestionario; } - + async findCompleto(id: number) { // Obtener el cuestionario básico const cuestionario = await this.cuestionarioRepository.findOne({ where: { id_cuestionario: id }, - relations: ['evento'] // Incluir la relación con el evento + relations: ['evento'], // Incluir la relación con el evento }); - + if (!cuestionario) { throw new NotFoundException(`Cuestionario con ID ${id} no encontrado`); } - + // Para fines de demostración, usar valores hardcodeados // para el tipo de cuestionario según get_formulario_feria.ts const tipoCuestionario = { id_tipo_cuestionario: 1, - tipo_cuestionario: 'Encuesta' + 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' } + 1: { id_tipo: 1, tipo_pregunta: TipoPreguntaEnum.AbiertaParrafo }, + 2: { id_tipo: 2, tipo_pregunta: TipoPreguntaEnum.AbiertaRespuestaCorta }, + 3: { id_tipo: 3, tipo_pregunta: TipoPreguntaEnum.Cerrada }, + 4: { id_tipo: 4, tipo_pregunta: TipoPreguntaEnum.Multiple }, }; - + // Obtener las relaciones cuestionario-seccion - const cuestionarioSecciones = await this.cuestionarioSeccionRepository.find({ - where: { id_cuestionario: id }, - order: { posicion: 'ASC' } - }); - + const cuestionarioSecciones = await this.cuestionarioSeccionRepository.find( + { + where: { id_cuestionario: id }, + order: { posicion: 'ASC' }, + }, + ); + // Obtener todas las secciones - const seccionIds = cuestionarioSecciones.map(cs => cs.id_seccion); + const seccionIds = cuestionarioSecciones.map((cs) => cs.id_seccion); const secciones = await this.seccionRepository.find({ - where: { id_seccion: In(seccionIds) } + where: { id_seccion: In(seccionIds) }, }); - + const seccionesFormateadas = await Promise.all( cuestionarioSecciones.map(async (cs) => { - const seccion = secciones.find(s => s.id_seccion === cs.id_seccion); - + const seccion = secciones.find((s) => s.id_seccion === cs.id_seccion); + if (!seccion) return null; - + // Obtener relaciones sección-pregunta const seccionPreguntas = await this.seccionPreguntaRepository.find({ where: { id_seccion: seccion.id_seccion }, - order: { posicion: 'ASC' } + order: { posicion: 'ASC' }, }); - + // Obtener preguntas - const preguntaIds = seccionPreguntas.map(sp => sp.id_pregunta); + const preguntaIds = seccionPreguntas.map((sp) => sp.id_pregunta); const preguntas = await this.preguntaRepository.find({ - where: { id_pregunta: In(preguntaIds) } + where: { id_pregunta: In(preguntaIds) }, }); - + // Formatear cada pregunta según el formato requerido const preguntasFormateadas = await Promise.all( seccionPreguntas.map(async (sp) => { - const pregunta = preguntas.find(p => p.id_pregunta === sp.id_pregunta); - + const pregunta = preguntas.find( + (p) => p.id_pregunta === sp.id_pregunta, + ); + if (!pregunta) return null; - + // 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' }; - + const tipoPregunta = tiposPreguntaMap[ + pregunta.id_tipo_pregunta + ] || { + id_tipo: pregunta.id_tipo_pregunta, + tipo_pregunta: 'Desconocido', + }; + // Obtener opciones para preguntas de tipo multiple/radio const preguntaOpciones = await this.preguntaOpcionRepository.find({ where: { id_pregunta: pregunta.id_pregunta }, relations: ['opcion'], - order: { posicion: 'ASC' } + order: { posicion: 'ASC' }, }); - + // Formatear las opciones según el formato requerido - const opcionesFormateadas = preguntaOpciones.map(po => ({ + const opcionesFormateadas = preguntaOpciones.map((po) => ({ id_pregunta_opcion: po.id_pregunta_opcion, posicion: po.posicion, id_opcion: po.opcion.id_opcion, opcion: { id_opcion: po.opcion.id_opcion, - opcion: po.opcion.opcion - } + opcion: po.opcion.opcion, + }, })); - + + console.log(opcionesFormateadas); + // Construir objeto de pregunta según el formato requerido return { id_seccion_pregunta: sp.id_seccion_pregunta, @@ -301,18 +500,17 @@ export class CuestionarioService { contador_opcion: pregunta.contador_opcion, 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 + tipo_pregunta: tipoPregunta.tipo_pregunta, }, - opciones: opcionesFormateadas - } + opciones: opcionesFormateadas, + }, }; - }) - ).then(results => results.filter(p => p !== null)); - + }), + ).then((results) => results.filter((p) => p !== null)); + // Construir objeto de sección según el formato requerido return { id_cuestionario_seccion: cs.id_cuestionario_seccion, @@ -321,39 +519,48 @@ export class CuestionarioService { id_seccion: seccion.id_seccion, contador_pregunta: seccion.contador_pregunta, descripcion: seccion.descripcion, - titulo: seccion.titulo + titulo: seccion.titulo, }, - preguntas: preguntasFormateadas + preguntas: preguntasFormateadas, }; - }) - ).then(results => results.filter(s => s !== null)); - + }), + ).then((results) => results.filter((s) => s !== null)); + // Construir objeto final respetando el formato de get_formulario_feria.ts return { tipo_cuestionario: { id_tipo_cuestionario: tipoCuestionario.id_tipo_cuestionario, - tipo_cuestionario: tipoCuestionario.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, + 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, contador_secciones: cuestionario.contador_secciones, descripcion: cuestionario.descripcion, editable: cuestionario.editable, - fecha_inicio: cuestionario.fecha_inicio ? cuestionario.fecha_inicio.toISOString() : null, - fecha_fin: cuestionario.fecha_fin ? cuestionario.fecha_fin.toISOString() : null, - id_cuestionario_original: cuestionario.id_cuestionario_original, + fecha_inicio: cuestionario.fecha_inicio + ? cuestionario.fecha_inicio.toISOString() + : null, + fecha_fin: cuestionario.fecha_fin + ? cuestionario.fecha_fin.toISOString() + : null, id_tipo_cuestionario: cuestionario.id_tipo_cuestionario, id_evento: cuestionario.id_evento || null, - secciones: seccionesFormateadas - } + secciones: seccionesFormateadas, + }, }; } @@ -362,31 +569,31 @@ export class CuestionarioService { if (updateCuestionarioDto.evento) { // Buscar o crear evento let evento = await this.eventoRepository.findOne({ - where: { nombre_evento: updateCuestionarioDto.evento } + 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) + 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); } diff --git a/src/cuestionario/cuestionario.documentation.ts b/src/cuestionario/docs/cuestionario.documentation.ts similarity index 51% rename from src/cuestionario/cuestionario.documentation.ts rename to src/cuestionario/docs/cuestionario.documentation.ts index 8caf16a..cf8884f 100644 --- a/src/cuestionario/cuestionario.documentation.ts +++ b/src/cuestionario/docs/cuestionario.documentation.ts @@ -1,87 +1,126 @@ -import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags, getSchemaPath } from '@nestjs/swagger'; -import { feriaSexualidad } from '../../utils/crear_formulario_feria'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, + getSchemaPath, +} from '@nestjs/swagger'; +import { feriaSexualidad } from '../../../utils/crear_formulario_feria'; import { applyDecorators } from '@nestjs/common'; -import { FormularioDto, FormularioFeriaSchema } from './dto/formulario.schema'; +import { FormularioDto } from '../dto/formulario.schema'; +import { + CreateCuestionarioDto, + CreateCuestionarioEventoDto, +} from '../dto/create-cuestionario.dto'; +import { ejemploCuestionarioAlumno } from './cuestionario.examples'; export class CuestionarioApiDocumentation { // Decoradores para toda la clase del controlador - static ApiController = ApiTags('Cuestionarios'); - + static ApiController = ApiTags('Cuestionario'); + + static ApiCreate = applyDecorators( + ApiOperation({ + summary: 'Crear un nuevo cuestionario para un evento existente', + description: + 'Este endpoint crea un cuestionario con sus secciones, preguntas y opciones. Se debe proporcionar el ID de un evento existente para asociarlo.', + }), + ApiBody({ + type: CreateCuestionarioDto, + examples: { + comunidad_estudiantil: ejemploCuestionarioAlumno, + }, + }), + ); + // Documentación para obtener el formulario completo static ApiGetFormulario = applyDecorators( ApiOperation({ summary: 'Obtener formulario completo', - 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' + 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', description: 'ID del cuestionario', required: true, type: 'number', - example: 1 + example: 1, }), ApiResponse({ status: 200, - 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.)', + 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': { schema: { $ref: getSchemaPath(FormularioDto) }, - example: feriaSexualidad - } - } + example: feriaSexualidad, + }, + }, }), - ApiResponse({ status: 404, description: 'Cuestionario no encontrado' }) + ApiResponse({ status: 404, description: 'Cuestionario no encontrado' }), ); - // Documentación para crear un cuestionario - static ApiCreate = applyDecorators( + static ApiCreateWithEvento = applyDecorators( ApiOperation({ - summary: 'Crear un nuevo cuestionario', - description: 'Crea un nuevo cuestionario con secciones, preguntas y opciones, incluyendo validaciones personalizadas' + summary: 'Crear un nuevo cuestionario con nuevo evento', + description: + 'Este endpoint crea un cuestionario y también un evento (si no existe) usando el nombre proporcionado. No se requiere `id_evento`, sólo `evento` (nombre del evento, es opcional).', + }), + ApiResponse({ + status: 201, + description: 'Cuestionario y evento creados exitosamente', + }), + ApiResponse({ + status: 400, + description: 'Datos inválidos', }), ApiBody({ - 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, + type: CreateCuestionarioEventoDto, examples: { - feriaSexualidad: { - summary: 'Ejemplo de cuestionario 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 - } - } + ejemplo_con_evento_nuevo: { + summary: 'Cuestionario que crea evento automáticamente', + value: { + nombre_form: 'Registro de participantes', + descripcion: 'Formulario para inscripción de asistentes.', + evento: 'Foro Juvenil de Tecnología 2025', + fecha_inicio: '2025-07-20T10:00:00', + fecha_fin: '2025-07-20T17:00:00', + id_tipo_cuestionario: 2, + secciones: [ + { + titulo: 'Datos generales', + descripcion: 'Recopilación de información personal', + preguntas: [ + { + titulo: '¿Cuál es tu edad?', + tipo: 'Abierta', + obligatoria: true, + }, + { + titulo: '¿Cómo te enteraste del evento?', + tipo: 'Multiple', + opciones: [ + { valor: 'Redes sociales' }, + { valor: 'Correo electrónico' }, + { valor: 'Por un amigo' }, + ], + }, + ], + }, + ], + }, + }, + }, }), - ApiResponse({ - status: 201, - 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 }, - cuestionario: { - type: 'object', - properties: { - id_cuestionario: { type: 'number', example: 1 }, - nombre_form: { type: 'string', example: 'Registro para la Feria de la Sexualidad - FES Acatlán' }, - descripcion: { type: 'string' }, - contador_secciones: { type: 'number', example: 1 }, - editable: { type: 'boolean', example: true }, - fecha_fin: { type: 'string', format: 'date-time' }, - fecha_inicio: { type: 'string', format: 'date-time' }, - id_tipo_cuestionario: { type: 'number', example: 1 } - } - } - } - } - }), - ApiResponse({ status: 400, description: 'Datos del cuestionario inválidos' }), - ApiResponse({ status: 500, description: 'Error interno del servidor' }) ); // Documentación para obtener todos los cuestionarios static ApiGetAll = applyDecorators( ApiOperation({ summary: 'Obtener todos los cuestionarios', - description: 'Retorna una lista de todos los cuestionarios registrados' + description: 'Retorna una lista de todos los cuestionarios registrados', }), ApiResponse({ status: 200, @@ -92,32 +131,35 @@ export class CuestionarioApiDocumentation { type: 'object', properties: { id_cuestionario: { type: 'number', example: 1 }, - nombre_form: { type: 'string', example: 'Registro para la Feria de la Sexualidad - FES Acatlán' }, + nombre_form: { + type: 'string', + example: 'Registro para la Feria de la Sexualidad - FES Acatlán', + }, descripcion: { type: 'string' }, contador_secciones: { type: 'number', example: 1 }, editable: { type: 'boolean', example: true }, fecha_fin: { type: 'string', format: 'date-time' }, fecha_inicio: { type: 'string', format: 'date-time' }, - id_tipo_cuestionario: { type: 'number', example: 1 } - } - } - } + id_tipo_cuestionario: { type: 'number', example: 1 }, + }, + }, + }, }), - ApiResponse({ status: 500, description: 'Error interno del servidor' }) + ApiResponse({ status: 500, description: 'Error interno del servidor' }), ); // Documentación para obtener un cuestionario por ID static ApiGetOne = applyDecorators( ApiOperation({ summary: 'Obtener un cuestionario por ID', - description: 'Retorna un cuestionario específico por su ID' + description: 'Retorna un cuestionario específico por su ID', }), ApiParam({ name: 'id', description: 'ID del cuestionario', required: true, type: 'number', - example: 1 + example: 1, }), ApiResponse({ status: 200, @@ -126,45 +168,51 @@ export class CuestionarioApiDocumentation { type: 'object', properties: { id_cuestionario: { type: 'number', example: 1 }, - nombre_form: { type: 'string', example: 'Registro para la Feria de la Sexualidad - FES Acatlán' }, + nombre_form: { + type: 'string', + example: 'Registro para la Feria de la Sexualidad - FES Acatlán', + }, descripcion: { type: 'string' }, contador_secciones: { type: 'number', example: 1 }, editable: { type: 'boolean', example: true }, fecha_fin: { type: 'string', format: 'date-time' }, fecha_inicio: { type: 'string', format: 'date-time' }, - id_tipo_cuestionario: { type: 'number', example: 1 } - } - } + id_tipo_cuestionario: { type: 'number', example: 1 }, + }, + }, }), ApiResponse({ status: 404, description: 'Cuestionario no encontrado' }), - ApiResponse({ status: 500, description: 'Error interno del servidor' }) + ApiResponse({ status: 500, description: 'Error interno del servidor' }), ); // Documentación para actualizar un cuestionario static ApiUpdate = applyDecorators( ApiOperation({ summary: 'Actualizar un cuestionario', - description: 'Actualiza los datos de un cuestionario existente' + description: 'Actualiza los datos de un cuestionario existente', }), ApiParam({ name: 'id', description: 'ID del cuestionario a actualizar', required: true, type: 'number', - example: 1 + example: 1, }), ApiBody({ description: 'Datos a actualizar del cuestionario', schema: { type: 'object', properties: { - nombre_form: { type: 'string', example: 'Nombre actualizado del formulario' }, + nombre_form: { + type: 'string', + example: 'Nombre actualizado del formulario', + }, descripcion: { type: 'string', example: 'Descripción actualizada' }, fecha_inicio: { type: 'string', format: 'date-time' }, fecha_fin: { type: 'string', format: 'date-time' }, - editable: { type: 'boolean', example: true } - } - } + editable: { type: 'boolean', example: true }, + }, + }, }), ApiResponse({ status: 200, @@ -172,27 +220,30 @@ export class CuestionarioApiDocumentation { schema: { type: 'object', properties: { - affected: { type: 'number', example: 1 } - } - } + affected: { type: 'number', example: 1 }, + }, + }, + }), + ApiResponse({ + status: 400, + description: 'Datos de actualización inválidos', }), - ApiResponse({ status: 400, description: 'Datos de actualización inválidos' }), ApiResponse({ status: 404, description: 'Cuestionario no encontrado' }), - ApiResponse({ status: 500, description: 'Error interno del servidor' }) + ApiResponse({ status: 500, description: 'Error interno del servidor' }), ); // Documentación para eliminar un cuestionario static ApiRemove = applyDecorators( ApiOperation({ summary: 'Eliminar un cuestionario', - description: 'Elimina permanentemente un cuestionario por su ID' + description: 'Elimina permanentemente un cuestionario por su ID', }), ApiParam({ name: 'id', description: 'ID del cuestionario a eliminar', required: true, type: 'number', - example: 1 + example: 1, }), ApiResponse({ status: 200, @@ -200,11 +251,11 @@ export class CuestionarioApiDocumentation { schema: { type: 'object', properties: { - affected: { type: 'number', example: 1 } - } - } + affected: { type: 'number', example: 1 }, + }, + }, }), ApiResponse({ status: 404, description: 'Cuestionario no encontrado' }), - ApiResponse({ status: 500, description: 'Error interno del servidor' }) + ApiResponse({ status: 500, description: 'Error interno del servidor' }), ); -} \ No newline at end of file +} diff --git a/src/cuestionario/docs/cuestionario.examples.ts b/src/cuestionario/docs/cuestionario.examples.ts new file mode 100644 index 0000000..10fb0a7 --- /dev/null +++ b/src/cuestionario/docs/cuestionario.examples.ts @@ -0,0 +1,139 @@ +import { TiposValidacion } from 'src/pregunta/entities/pregunta.entity'; + +export const ejemploCuestionarioAlumno = { + summary: + 'Cuestionario para el registro de asistencia a un evento para comunidad estudiantil', + value: { + id_evento: 1, + nombre_form: 'Registro', + descripcion: + 'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.', + fecha_inicio: '2025-08-01T08:00:00', + fecha_fin: '2025-08-10T23:59:59', + id_tipo_cuestionario: 1, + secciones: [ + { + titulo: 'Información Personal', + descripcion: + 'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.', + preguntas: [ + { + titulo: '¿Eres parte de la comunidad de la FES Acatlán?', + tipo: 'Cerrada', + opciones: [{ valor: 'Si' }, { valor: 'No' }], + obligatoria: true, + validacion: TiposValidacion.COMUNIDAD_ALUMNO, + }, + { + titulo: 'Numero de cuenta', + tipo: 'Abierta (Respuesta corta)', + obligatoria: false, + validacion: TiposValidacion.CUENTA_ALUMNO, + }, + { + titulo: 'Correo electrónico', + tipo: 'Abierta (Respuesta corta)', + obligatoria: true, + validacion: TiposValidacion.CORREO, + }, + { + titulo: 'Nombre(s)', + tipo: 'Abierta (Respuesta corta)', + obligatoria: true, + validacion: TiposValidacion.NOMBRE, + }, + { + titulo: 'Apellidos', + tipo: 'Abierta (Respuesta corta)', + obligatoria: true, + validacion: TiposValidacion.NOMBRE, + }, + { + titulo: 'Género', + tipo: 'Cerrada', + obligatoria: true, + opciones: [ + { valor: 'Masculino' }, + { valor: 'Femenino' }, + { valor: 'Prefiero no decirlo' }, + ], + }, + { + titulo: 'Institución de procedencia', + tipo: 'Abierta (Respuesta corta)', + obligatoria: true, + }, + { + titulo: 'Carrera', + tipo: 'Abierta (Respuesta corta)', + obligatoria: true, + }, + ], + }, + ], + }, +}; + +export const ejemploCuestionarioTrabajador = { + summary: + 'Cuestionario para el registro de asistencia a un evento para comunidad trabajadora', + value: { + id_evento: 1, + nombre_form: 'Registro', + descripcion: + 'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.', + fecha_inicio: '2025-08-01T08:00:00', + fecha_fin: '2025-08-10T23:59:59', + id_tipo_cuestionario: 2, + secciones: [ + { + titulo: 'Información Personal', + descripcion: + 'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.', + preguntas: [ + { + titulo: '¿Eres parte de la comunidad de la FES Acatlán?', + tipo: 'Cerrada', + opciones: [{ valor: 'Si' }, { valor: 'No' }], + obligatoria: true, + validacion: TiposValidacion.COMUNIDAD_TRABAJADOR, + }, + { + titulo: 'Número de trabajador', + tipo: 'Abierta (Respuesta corta)', + obligatoria: false, + validacion: TiposValidacion.CUENTA_TRABAJADOR, + }, + { + titulo: 'Correo electrónico institucional', + tipo: 'Abierta (Respuesta corta)', + obligatoria: true, + validacion: TiposValidacion.CORREO_INSTITUCIONAL, + }, + { + titulo: 'Nombre(s)', + tipo: 'Abierta (Respuesta corta)', + obligatoria: true, + validacion: TiposValidacion.NOMBRE, + }, + { + titulo: 'Apellidos', + tipo: 'Abierta (Respuesta corta)', + obligatoria: true, + validacion: TiposValidacion.NOMBRE, + }, + { + titulo: 'Género', + tipo: 'Cerrada', + obligatoria: true, + opciones: [ + { valor: 'Masculino' }, + { valor: 'Femenino' }, + { valor: 'Prefiero no decirlo' }, + ], + }, + ], + }, + ], + }, +}; diff --git a/src/cuestionario/dto/create-cuestionario.dto.ts b/src/cuestionario/dto/create-cuestionario.dto.ts index ee07e94..eb9c642 100644 --- a/src/cuestionario/dto/create-cuestionario.dto.ts +++ b/src/cuestionario/dto/create-cuestionario.dto.ts @@ -1,12 +1,30 @@ import { CreateSeccionDto } from '../../seccion/dto/create-seccion.dto'; -import { IsArray, IsDateString, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { + IsArray, + IsDateString, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; +import { OmitType } from '@nestjs/mapped-types'; export class CreateCuestionarioDto { + @ApiProperty({ + description: 'ID del evento al que pertenece el cuestionario', + example: 1, + required: false, + }) + @IsNotEmpty() + @IsNumber() + id_evento: number; + @ApiProperty({ description: 'Nombre del formulario', - example: 'Registro para la Feria de la Sexualidad - FES Acatlán' + example: 'Registro para la Feria de la Sexualidad - FES Acatlán', }) @IsNotEmpty() @IsString() @@ -15,7 +33,7 @@ export class CreateCuestionarioDto { @ApiProperty({ description: 'Descripción del formulario', example: 'Formulario de registro para la feria...', - required: false + required: false, }) @IsOptional() @IsString() @@ -24,33 +42,31 @@ export class CreateCuestionarioDto { @ApiProperty({ description: 'Fecha de inicio', example: '2025-03-26T00:00:00', - required: false + required: false, }) - @IsOptional() @IsDateString() - fecha_inicio?: Date; + fecha_inicio: Date; @ApiProperty({ description: 'Fecha de fin', example: '2025-03-31T23:59:59', - required: false + required: false, }) - @IsOptional() @IsDateString() - fecha_fin?: Date; + fecha_fin: Date; @ApiProperty({ description: 'ID del tipo de cuestionario', - example: 1 + example: 1, }) @IsNotEmpty() @IsNumber() id_tipo_cuestionario: number; - + @ApiProperty({ description: 'Nombre del evento asociado', example: 'Feria de la Sexualidad', - required: false + required: false, }) @IsOptional() @IsString() @@ -59,7 +75,7 @@ export class CreateCuestionarioDto { @ApiProperty({ description: 'Secciones del cuestionario', type: [CreateSeccionDto], - required: false + required: false, }) @IsOptional() @IsArray() @@ -67,3 +83,8 @@ export class CreateCuestionarioDto { @Type(() => CreateSeccionDto) secciones?: CreateSeccionDto[]; } + +export class CreateCuestionarioEventoDto extends OmitType( + CreateCuestionarioDto, + ['id_evento'] as const, +) {} diff --git a/src/cuestionario/dto/formulario.schema.ts b/src/cuestionario/dto/formulario.schema.ts index f1478be..00205cc 100644 --- a/src/cuestionario/dto/formulario.schema.ts +++ b/src/cuestionario/dto/formulario.schema.ts @@ -1,5 +1,4 @@ import { ApiProperty } from '@nestjs/swagger'; -import { TipoPreguntaEnum } from '../../pregunta/dto/create-pregunta.dto'; // Ejemplo de respuesta de cuestionario completo export const FormularioFeriaSchema = { @@ -37,7 +36,6 @@ export const FormularioFeriaSchema = { contador_opcion: 2, obligatoria: true, id_tipo_pregunta: 1, - id_opcion_dependiente: null, tipo_pregunta: { id_tipo: 1, tipo_pregunta: 'Cerrada' @@ -73,7 +71,6 @@ export const FormularioFeriaSchema = { 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: { @@ -92,7 +89,6 @@ export const FormularioFeriaSchema = { 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: { @@ -123,7 +119,6 @@ export const FormularioFeriaSchema = { contador_opcion: 5, obligatoria: true, id_tipo_pregunta: 3, - id_opcion_dependiente: null, tipo_pregunta: { id_tipo: 3, tipo_pregunta: 'Multiple' @@ -186,7 +181,6 @@ export const FormularioFeriaSchema = { 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, diff --git a/src/cuestionario/entities/cuestionario.entity.ts b/src/cuestionario/entities/cuestionario.entity.ts index 535d2b2..ca73447 100644 --- a/src/cuestionario/entities/cuestionario.entity.ts +++ b/src/cuestionario/entities/cuestionario.entity.ts @@ -1,9 +1,15 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToMany, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { CuestionarioSeccion } from 'src/cuestionario_seccion/entities/cuestionario_seccion.entity'; -import { Evento } from 'src/evento/evento.entity'; +import { Evento } from 'src/evento/entities/evento.entity'; +import { ParticipanteEvento } from 'src/participante_evento/entities/participante_evento.entity'; import { TipoCuestionario } from 'src/tipo_cuestionario/entities/tipo_cuestionario.entity'; -import { Entity, PrimaryGeneratedColumn, Column, OneToMany, ManyToMany, ManyToOne, JoinColumn } from 'typeorm'; - - @Entity('cuestionario') export class Cuestionario { @@ -13,6 +19,9 @@ export class Cuestionario { @Column({ type: 'text' }) nombre_form: string; + @Column({ type: 'text', nullable: true }) + banner: string; + @Column({ type: 'int' }) contador_secciones: number; @@ -22,29 +31,40 @@ export class Cuestionario { @Column({ type: 'boolean', default: true }) editable: boolean; - @Column({ type: 'datetime', nullable: true }) - fecha_fin?: Date; + @Column({ type: 'datetime' }) + fecha_inicio: Date; - @Column({ type: 'datetime', nullable: true }) - fecha_inicio?: Date; - - - @OneToMany(() => CuestionarioSeccion,(cuestionario_seccion) => cuestionario_seccion.id_cuestionario) - cuestionarioSeccion:CuestionarioSeccion[] - - @Column({ type: 'int', nullable: true }) - id_cuestionario_original: number; + @Column({ type: 'datetime' }) + fecha_fin: Date; @Column({ type: 'int' }) id_tipo_cuestionario: number; - @ManyToOne(()=> Cuestionario) - cuestionarioOriginal: Cuestionario; - - @Column({ type: 'int', nullable: true }) + @Column({ type: 'int' }) id_evento: number; - - @ManyToOne(() => Evento) + + // Relaciones + @ManyToOne(() => Cuestionario, { nullable: true }) + @JoinColumn({ name: 'id_cuestionario_original' }) + cuestionarioOriginal?: Cuestionario; + + @ManyToOne(() => Evento, (evento) => evento.cuestionarios, { nullable: true }) @JoinColumn({ name: 'id_evento' }) - evento: Evento; + evento?: Evento; + + @ManyToOne(() => TipoCuestionario, (tipo) => tipo.cuestionarios) + @JoinColumn({ name: 'id_tipo_cuestionario' }) + tipoCuestionario: TipoCuestionario; + + @OneToMany( + () => CuestionarioSeccion, + (cuestionarioSeccion) => cuestionarioSeccion.cuestionario, + ) + cuestionarioSeccion: CuestionarioSeccion[]; + + @OneToMany( + () => ParticipanteEvento, + (participanteEvento) => participanteEvento.cuestionario, + ) + inscripciones: ParticipanteEvento[]; } diff --git a/src/cuestionario_respondido/cuestionario_respondido.controller.spec.ts b/src/cuestionario_respondido/cuestionario_respondido.controller.spec.ts deleted file mode 100644 index c96795c..0000000 --- a/src/cuestionario_respondido/cuestionario_respondido.controller.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CuestionarioRespondidoController } from './cuestionario_respondido.controller'; -import { CuestionarioRespondidoService } from './cuestionario_respondido.service'; - -describe('CuestionarioRespondidoController', () => { - let controller: CuestionarioRespondidoController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [CuestionarioRespondidoController], - providers: [CuestionarioRespondidoService], - }).compile(); - - controller = module.get(CuestionarioRespondidoController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/cuestionario_respondido/cuestionario_respondido.controller.ts b/src/cuestionario_respondido/cuestionario_respondido.controller.ts index 3e39867..2b0a3b0 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.controller.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.controller.ts @@ -18,17 +18,7 @@ export class CuestionarioRespondidoController { } @Post('submit') - @CuestionarioRespondidoApiDocumentation.ApiSubmitRespuestas - @ApiBody({ - type: SubmitRespuestasDto, - examples: { - comunidad: CuestionarioRespondidoApiDocumentation.ApiSubmitRespuestasExamples.communityExample, - externos: CuestionarioRespondidoApiDocumentation.ApiSubmitRespuestasExamples.externalExample - } - }) - @CuestionarioRespondidoApiDocumentation.ApiSubmitResponse201 - @CuestionarioRespondidoApiDocumentation.ApiSubmitResponse400 - @CuestionarioRespondidoApiDocumentation.ApiSubmitResponse404 + @CuestionarioRespondidoApiDocumentation.ApiSubmit submitRespuestas(@Body() submitRespuestasDto: SubmitRespuestasDto) { return this.cuestionarioRespondidoService.submitRespuestas(submitRespuestasDto); } diff --git a/src/cuestionario_respondido/cuestionario_respondido.documentation.ts b/src/cuestionario_respondido/cuestionario_respondido.documentation.ts index 531e303..c50edb6 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.documentation.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.documentation.ts @@ -1,26 +1,81 @@ -import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { applyDecorators } from '@nestjs/common'; +import { SubmitRespuestasDto } from './dto/submit-respuestas.dto'; + +export const ejemploEnvioRespuestas = { + summary: 'Ejemplo de envío de respuestas a un formulario', + value: { + id_cuestionario: 1, + correo: 'miguel@acatlan.unam.mx', + fecha_envio: '2025-07-10T10:15:00', // opcional + + respuestas: [ + { + id_pregunta: 1, // ¿Eres parte de la comunidad de la FES Acatlán? + valor: 1, // Si (id_opcion: 1) + }, + { + id_pregunta: 2, // Numero de cuenta + valor: '123456789', // texto + }, + { + id_pregunta: 3, // Correo electrónico + valor: 'miguel@acatlan.unam.mx', + }, + { + id_pregunta: 4, // Nombre(s) + valor: 'Liliana', + }, + { + id_pregunta: 5, // Apellidos + valor: 'Alvarado Díaz', + }, + { + id_pregunta: 6, // Género + valor: 4, // Femenino (id_opcion: 4) + }, + { + id_pregunta: 7, // Institución de procedencia + valor: 'FES Acatlán', + }, + { + id_pregunta: 8, // Carrera + valor: 'Matemáticas Aplicadas y Computación', + }, + ], + }, +}; 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 ApiSubmit = applyDecorators( + ApiOperation({ + summary: 'Enviar respuestas a un cuestionario', + description: `Este endpoint permite a un participante enviar respuestas a un cuestionario previamente creado. +Se asocia el formulario con el participante mediante su correo electrónico. Si es la primera vez que responde, +se registra automáticamente. + +Las respuestas pueden ser de tipo: +- Abierta (texto) +- Cerrada (una opción) +- Múltiple (varias opciones) + +Además, si el cuestionario está ligado a un evento, se registra su participación en dicho evento y se le envía un correo con un código QR para validar asistencia.`, + }), + ApiBody({ + type: SubmitRespuestasDto, + examples: { + envio_respuestas: ejemploEnvioRespuestas, + }, + }), + ); static ApiSubmitResponse201 = ApiResponse({ status: 201, @@ -28,10 +83,13 @@ export class CuestionarioRespondidoApiDocumentation { schema: { properties: { success: { type: 'boolean', example: true }, - message: { type: 'string', example: 'Respuestas guardadas correctamente' }, - cuestionarioRespondidoId: { type: 'number', example: 1 } - } - } + message: { + type: 'string', + example: 'Respuestas guardadas correctamente', + }, + cuestionarioRespondidoId: { type: 'number', example: 1 }, + }, + }, }); static ApiSubmitResponse400 = ApiResponse({ @@ -40,10 +98,13 @@ export class CuestionarioRespondidoApiDocumentation { 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' } - } - } + 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({ @@ -52,10 +113,13 @@ export class CuestionarioRespondidoApiDocumentation { schema: { properties: { statusCode: { type: 'number', example: 404 }, - message: { type: 'string', example: 'Cuestionario con ID 999 no encontrado' }, - error: { type: 'string', example: 'Not Found' } - } - } + message: { + type: 'string', + example: 'Cuestionario con ID 999 no encontrado', + }, + error: { type: 'string', example: 'Not Found' }, + }, + }, }); static get ApiReporteRespuestas() { @@ -74,16 +138,17 @@ export class CuestionarioRespondidoApiDocumentation { - Cuestionario - Pregunta - Respuesta - ` + `, }), ApiResponse({ status: 200, - description: 'Reporte de respuestas descargado exitosamente como archivo CSV' + description: + 'Reporte de respuestas descargado exitosamente como archivo CSV', }), ApiResponse({ status: 404, - description: 'Cuestionario no encontrado' - }) + description: 'Cuestionario no encontrado', + }), ); } @@ -97,14 +162,16 @@ export class CuestionarioRespondidoApiDocumentation { { 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) + { 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 con respuestas abiertas y opciones por ID.' + 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', + summary: + 'Ejemplo de respuesta para un participante externo con selección múltiple', value: { id_formulario: 1, correo: 'externo@ejemplo.com', @@ -115,11 +182,12 @@ export class CuestionarioRespondidoApiDocumentation { { 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 + { 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 con respuestas abiertas, opciones por ID y selección múltiple.' - } + description: + 'Datos de un participante externo con respuestas abiertas, opciones por ID y selección múltiple.', + }, }; -} \ No newline at end of file +} diff --git a/src/cuestionario_respondido/cuestionario_respondido.module.ts b/src/cuestionario_respondido/cuestionario_respondido.module.ts index d0cdb61..b0ba224 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.module.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.module.ts @@ -5,7 +5,7 @@ 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 { Participante } from '../participante/entities/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'; @@ -24,14 +24,14 @@ import { ValidacionesModule } from '../validaciones/validaciones.module'; RespuestaParticipanteAbierta, RespuestaParticipanteCerrada, PreguntaOpcion, - Opcion + Opcion, ]), CuestionarioModule, ParticipanteModule, - ValidacionesModule + ValidacionesModule, ], controllers: [CuestionarioRespondidoController], providers: [CuestionarioRespondidoService], - exports: [TypeOrmModule] + exports: [TypeOrmModule], }) export class CuestionarioRespondidoModule {} diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.spec.ts b/src/cuestionario_respondido/cuestionario_respondido.service.spec.ts deleted file mode 100644 index 477946b..0000000 --- a/src/cuestionario_respondido/cuestionario_respondido.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { CuestionarioRespondidoService } from './cuestionario_respondido.service'; - -describe('CuestionarioRespondidoService', () => { - let service: CuestionarioRespondidoService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [CuestionarioRespondidoService], - }).compile(); - - service = module.get(CuestionarioRespondidoService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index cf248a9..e7677e0 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -1,11 +1,15 @@ -import { BadRequestException, Injectable, NotFoundException } 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 { Participante } from '../participante/entities/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'; @@ -15,6 +19,9 @@ import { Opcion } from '../opcion/entities/opcion.entity'; import { ValidadorRespuestasService } from '../validaciones/validador-respuestas.service'; import axios from 'axios'; import * as QRCode from 'qrcode'; +import { CuestionarioService } from 'src/cuestionario/cuestionario.service'; +import { generarHtmlCorreoAsistencia } from 'src/emails/registro-evento'; +import { TipoPreguntaEnum } from 'src/tipo_pregunta/entities/tipo_pregunta.entity'; @Injectable() export class CuestionarioRespondidoService { constructor( @@ -26,16 +33,12 @@ export class CuestionarioRespondidoService { private cuestionarioRepository: Repository, @InjectRepository(Pregunta) private preguntaRepository: Repository, - @InjectRepository(RespuestaParticipanteAbierta) - private respuestaAbiertaRepository: Repository, - @InjectRepository(RespuestaParticipanteCerrada) - private respuestaCerradaRepository: Repository, @InjectRepository(PreguntaOpcion) private preguntaOpcionRepository: Repository, - @InjectRepository(Opcion) - private opcionRepository: Repository, + private dataSource: DataSource, private validadorRespuestasService: ValidadorRespuestasService, + private cuestionarioService: CuestionarioService, ) {} create(createCuestionarioRespondidoDto: CreateCuestionarioRespondidoDto) { @@ -49,67 +52,89 @@ export class CuestionarioRespondidoService { 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`); - } + const cuestionario = await this.cuestionarioService.getCuestionarioOrFail( + submitDto.id_cuestionario, + ); // 2. Buscar o crear participante let participante = await this.participanteRepository.findOne({ - where: { correo: submitDto.correo } + 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); } + // Verificar si el participante ya ha respondido este cuestionario + const cuestionarioRespondidoExistente = await queryRunner.manager.findOne( + CuestionarioRespondido, + { + where: { + cuestionario: { id_cuestionario: submitDto.id_cuestionario }, + participante: { id_participante: participante.id_participante }, + }, + relations: ['cuestionario', 'participante'], + }, + ); + if (cuestionarioRespondidoExistente) { + throw new BadRequestException( + `El participante con correo ${submitDto.correo} ya ha respondido el cuestionario ${cuestionario.nombre_form}.`, + ); + } + // 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); + 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'] + relations: ['tipoPregunta'], }); if (!pregunta) { - throw new NotFoundException(`Pregunta con ID ${respuestaDto.id_pregunta} no encontrada`); + 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 - ); - + 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}`); + 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'; - + const esPreguntaTipoAbierta = + pregunta.tipoPregunta.tipo_pregunta === + TipoPreguntaEnum.AbiertaParrafo || + pregunta.tipoPregunta.tipo_pregunta === + TipoPreguntaEnum.AbiertaRespuestaCorta; + // Determinar tipo de pregunta y guardar respuesta adecuadamente if (esPreguntaAbierta || esPreguntaTipoAbierta) { // Respuesta abierta (texto) @@ -123,34 +148,38 @@ export class CuestionarioRespondidoService { 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]; - + // Asegurarse de que opcionIds sea un array de números + const opcionIds = Array.isArray(respuestaDto.valor) + ? respuestaDto.valor.map((v) => Number(v)) + : [Number(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(); - + const preguntaOpciones = await this.preguntaOpcionRepository.find({ + where: { + id_pregunta: pregunta.id_pregunta, + id_opcion: opcionId, + }, + }); + if (preguntaOpciones.length === 0) { - throw new BadRequestException(`La opción con ID ${opcionId} no está asociada a la pregunta ${respuestaDto.id_pregunta}`); + 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; - + respuestaCerrada.cuestionarioRespondido = + savedCuestionarioRespondido; + await queryRunner.manager.save(respuestaCerrada); } } @@ -160,100 +189,109 @@ export class CuestionarioRespondidoService { let datosQR: { id_participante: number; id_evento: number; + id_cuestionario: number; } | null = null; - + + console.log(cuestionario.evento); + // 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 + id_evento: cuestionario.evento.id_evento, + id_cuestionario: cuestionario.id_cuestionario, }; } - + // 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) { + if (cuestionario && cuestionario.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] + `SELECT * FROM participante_evento WHERE id_participante = ? AND id_cuestionario = ?`, + [participante.id_participante, cuestionario.id_cuestionario], ); - + // Si no existe, crear la relación - if (!participanteEventoExistente || participanteEventoExistente.length === 0) { + 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] + `INSERT INTO participante_evento (id_participante, id_cuestionario, fecha_asistencia) VALUES (?, ?, ?)`, + [ + participante.id_participante, + cuestionario.id_cuestionario, + null, // o puedes omitir esta columna y no incluirla en el query + ], ); } } catch (dbError) { - console.error('Error al registrar participante en evento:', dbError.message); + 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'; - + const urlApiCorreos = process.env.url_api_correos; + + if (!urlApiCorreos) { + throw new BadRequestException( + 'No se encontro servicio de correos configurado', + ); + } + await axios.post(`${urlApiCorreos}/mail/send`, { to: participante.correo, - subject: 'Asistencia QR', - fecha_recibido: "2025-03-10T12:00:00Z", - html: ` -

¡Gracias por registrarte!

-

Has completado exitosamente el formulario: ${cuestionario.nombre_form}

-

Deberás presentar este QR (celular o impreso) en cualquier stand para que valide tu asistencia y -recibas tu constancia.

-

Este es tu QR para registrar asistencia:

- Código QR -

Tus datos de registro:

-
    -
  • Evento: ${cuestionario.evento.nombre_evento || 'Evento'}
  • -
  • Correo: ${participante.correo}
  • -
  • Fecha de registro: ${new Date().toLocaleString()}
  • -
- `, + subject: '🎓 Código QR para validar asistencia', + fecha_recibido: '2025-03-10T12:00:00Z', + html: generarHtmlCorreoAsistencia({ + nombreForm: cuestionario.nombre_form, + nombreEvento: cuestionario.evento?.nombre_evento, + correo: participante.correo, + fechaRegistro: new Date().toLocaleString(), + }), adjuntos: [ { filename: 'qr.png', content: qrBuffer.toString('base64'), encoding: 'base64', - cid: 'qrCode' - } - ] + 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 + 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 @@ -270,15 +308,23 @@ recibas tu constancia.

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'] - }); + 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(` + const respuestasCerradas = await this.dataSource.query( + ` SELECT rpc.idRespuestaParticipanteCerrada, rpc.id_pregunta_opcion, @@ -292,29 +338,34 @@ recibas tu constancia.

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]); + `, + [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 - } - })); + 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 + respuestasCerradas: respuestasCerradasFormateadas, }; - }) + }), ); return resultados; @@ -322,24 +373,28 @@ recibas tu constancia.

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' - ] - }); + 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`); + 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(` + const respuestasCerradas = await this.dataSource.query( + ` SELECT rpc.idRespuestaParticipanteCerrada, rpc.id_pregunta_opcion, @@ -353,35 +408,46 @@ recibas tu constancia.

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]); + `, + [id], + ); - console.log('Respuestas cerradas SQL:', JSON.stringify(respuestasCerradas, null, 2)); + 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 - } - })); + 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 + respuestasCerradas: respuestasCerradasFormateadas, }; return resultado; } - update(id: number, updateCuestionarioRespondidoDto: UpdateCuestionarioRespondidoDto) { + update( + id: number, + updateCuestionarioRespondidoDto: UpdateCuestionarioRespondidoDto, + ) { return `This action updates a #${id} cuestionarioRespondido`; } @@ -390,6 +456,101 @@ recibas tu constancia.

} async generarReporteRespuestas(idCuestionario: number): Promise { + const cuestionario = await this.cuestionarioRepository.findOne({ + where: { id_cuestionario: idCuestionario }, + relations: ['evento'], + }); + + if (!cuestionario) { + throw new NotFoundException( + `Cuestionario con ID ${idCuestionario} no encontrado`, + ); + } + + const preguntas = await this.dataSource.query( + ` + SELECT + p.id_pregunta, + p.pregunta + FROM pregunta p + JOIN seccion_pregunta sp ON p.id_pregunta = sp.id_pregunta + JOIN seccion s ON sp.id_seccion = s.id_seccion + JOIN cuestionario_seccion cs ON s.id_seccion = cs.id_seccion + WHERE cs.id_cuestionario = ? + ORDER BY sp.posicion, p.id_pregunta + `, + [idCuestionario], + ); + + const respuestasAbiertas = await this.dataSource.query( + ` + SELECT + cr.id_cuestionario_respondido, + p.id_pregunta, + rpa.respuesta + FROM respuesta_participante_abierta rpa + JOIN pregunta p ON rpa.id_pregunta = p.id_pregunta + JOIN cuestionario_respondido cr ON rpa.id_cuestionario_respondido = cr.id_cuestionario_respondido + WHERE cr.id_cuestionario = ? + `, + [idCuestionario], + ); + + const respuestasCerradas = await this.dataSource.query( + ` + SELECT + cr.id_cuestionario_respondido, + p.id_pregunta, + o.opcion + FROM respuesta_participante_cerrada rpc + JOIN pregunta_opcion po ON rpc.id_pregunta_opcion = po.id_pregunta_opcion + JOIN pregunta p ON po.id_pregunta = p.id_pregunta + JOIN opcion o ON po.id_opcion = o.id_opcion + JOIN cuestionario_respondido cr ON rpc.id_cuestionario_respondido = cr.id_cuestionario_respondido + WHERE cr.id_cuestionario = ? + `, + [idCuestionario], + ); + + const mapaRespuestas = new Map>(); + + respuestasAbiertas.forEach((r) => { + if (!mapaRespuestas.has(r.id_cuestionario_respondido)) { + mapaRespuestas.set(r.id_cuestionario_respondido, new Map()); + } + mapaRespuestas + .get(r.id_cuestionario_respondido)! + .set(r.id_pregunta, r.respuesta); + }); + + respuestasCerradas.forEach((r) => { + if (!mapaRespuestas.has(r.id_cuestionario_respondido)) { + mapaRespuestas.set(r.id_cuestionario_respondido, new Map()); + } + const mapa = mapaRespuestas.get(r.id_cuestionario_respondido)!; + const anterior = mapa.get(r.id_pregunta) || ''; + mapa.set(r.id_pregunta, anterior ? `${anterior}, ${r.opcion}` : r.opcion); + }); + + // Generar encabezado CSV + const encabezados = preguntas.map( + (p) => `"${p.pregunta.replace(/"/g, '""')}"`, + ); + let csv = encabezados.join(',') + '\n'; + + // Generar filas por participante + for (const [, mapa] of mapaRespuestas) { + const fila = preguntas.map((p) => { + const valor = mapa.get(p.id_pregunta) || ''; + return `"${valor.replace(/"/g, '""')}"`; // escapamos comillas dobles + }); + csv += fila.join(',') + '\n'; + } + + return csv; + } + + /* async generarReporteRespuestas(idCuestionario: number): Promise { // Buscar el cuestionario para validar que existe const cuestionario = await this.cuestionarioRepository.findOne({ where: { id_cuestionario: idCuestionario }, @@ -397,11 +558,14 @@ recibas tu constancia.

}); if (!cuestionario) { - throw new NotFoundException(`Cuestionario con ID ${idCuestionario} no encontrado`); + throw new NotFoundException( + `Cuestionario con ID ${idCuestionario} no encontrado`, + ); } // Obtener todas las preguntas del cuestionario para los encabezados - const preguntas = await this.dataSource.query(` + const preguntas = await this.dataSource.query( + ` SELECT p.id_pregunta, p.pregunta, @@ -412,10 +576,13 @@ recibas tu constancia.

JOIN cuestionario_seccion cs ON s.id_seccion = cs.id_seccion WHERE cs.id_cuestionario = ? ORDER BY sp.posicion, p.id_pregunta - `, [idCuestionario]); + `, + [idCuestionario], + ); // Obtener los participantes que han respondido este cuestionario - const participantes = await this.dataSource.query(` + const participantes = await this.dataSource.query( + ` SELECT DISTINCT cr.id_cuestionario_respondido, cr.fecha_respuesta, @@ -427,10 +594,16 @@ recibas tu constancia.

LEFT JOIN participante_evento pe ON p.id_participante = pe.id_participante AND pe.id_evento = ? WHERE cr.id_cuestionario = ? ORDER BY cr.fecha_respuesta DESC - `, [cuestionario.evento ? cuestionario.evento.id_evento : null, idCuestionario]); + `, + [ + cuestionario.evento ? cuestionario.evento.id_evento : null, + idCuestionario, + ], + ); // Obtener todas las respuestas para este cuestionario - const respuestasAbiertas = await this.dataSource.query(` + const respuestasAbiertas = await this.dataSource.query( + ` SELECT cr.id_cuestionario_respondido, p.id_pregunta, @@ -439,9 +612,12 @@ recibas tu constancia.

JOIN pregunta p ON rpa.id_pregunta = p.id_pregunta JOIN cuestionario_respondido cr ON rpa.id_cuestionario_respondido = cr.id_cuestionario_respondido WHERE cr.id_cuestionario = ? - `, [idCuestionario]); + `, + [idCuestionario], + ); - const respuestasCerradas = await this.dataSource.query(` + const respuestasCerradas = await this.dataSource.query( + ` SELECT cr.id_cuestionario_respondido, p.id_pregunta, @@ -452,25 +628,30 @@ recibas tu constancia.

JOIN opcion o ON po.id_opcion = o.id_opcion JOIN cuestionario_respondido cr ON rpc.id_cuestionario_respondido = cr.id_cuestionario_respondido WHERE cr.id_cuestionario = ? - `, [idCuestionario]); + `, + [idCuestionario], + ); // Crear un mapa de respuestas por participante y pregunta const respuestasPorParticipante = new Map(); - + // Agregar respuestas abiertas al mapa - respuestasAbiertas.forEach(respuesta => { + respuestasAbiertas.forEach((respuesta) => { const key = `${respuesta.id_cuestionario_respondido}_${respuesta.id_pregunta}`; respuestasPorParticipante.set(key, respuesta.respuesta); }); - + // Agregar respuestas cerradas al mapa (posiblemente múltiples por pregunta) - respuestasCerradas.forEach(respuesta => { + respuestasCerradas.forEach((respuesta) => { const key = `${respuesta.id_cuestionario_respondido}_${respuesta.id_pregunta}`; const respuestaActual = respuestasPorParticipante.get(key); - + if (respuestaActual) { // Si ya hay una respuesta para esta pregunta, agregar esta opción - respuestasPorParticipante.set(key, `${respuestaActual}, ${respuesta.opcion}`); + respuestasPorParticipante.set( + key, + `${respuestaActual}, ${respuesta.opcion}`, + ); } else { respuestasPorParticipante.set(key, respuesta.opcion); } @@ -478,54 +659,62 @@ recibas tu constancia.

// Preparar los datos para el CSV const datosReporte: string[][] = []; - + // Agregar encabezados con datos del participante seguidos por las preguntas const encabezados = [ - 'ID Participante', - 'Correo', + 'ID Participante', + 'Correo', 'Fecha Respuesta', - 'Asistencia' + 'Asistencia', ]; - + // Agregar cada pregunta como un encabezado - preguntas.forEach(pregunta => { + preguntas.forEach((pregunta) => { encabezados.push(pregunta.pregunta); }); - + datosReporte.push(encabezados); - + // Crear una fila por cada participante - participantes.forEach(participante => { + participantes.forEach((participante) => { const fila = [ participante.id_participante.toString(), participante.correo_participante, new Date(participante.fecha_respuesta).toLocaleString(), - participante.fecha_asistencia ? 'Sí' : 'No' + participante.fecha_asistencia ? 'Sí' : 'No', ]; - + // Agregar cada respuesta en el orden de las preguntas - preguntas.forEach(pregunta => { + preguntas.forEach((pregunta) => { const key = `${participante.id_cuestionario_respondido}_${pregunta.id_pregunta}`; const respuesta = respuestasPorParticipante.get(key) || ''; fila.push(respuesta); }); - + datosReporte.push(fila); }); - + // 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'); - + 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; - } + } */ } diff --git a/src/cuestionario_respondido/dto/submit-respuestas.dto.ts b/src/cuestionario_respondido/dto/submit-respuestas.dto.ts index 4781f76..c62a56b 100644 --- a/src/cuestionario_respondido/dto/submit-respuestas.dto.ts +++ b/src/cuestionario_respondido/dto/submit-respuestas.dto.ts @@ -1,21 +1,21 @@ import { ApiProperty } from '@nestjs/swagger'; -import { - IsArray, - IsDateString, - IsNotEmpty, - IsNumber, - IsOptional, - IsString, - ValidateNested, +import { + IsArray, + IsDateString, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + ValidateNested, ValidateIf, - MaxLength + MaxLength, } from 'class-validator'; import { Type, Transform } from 'class-transformer'; export class RespuestaDto { @ApiProperty({ description: 'ID de la pregunta respondida', - example: 101 + example: 101, }) @IsNumber() id_pregunta: number; @@ -23,40 +23,47 @@ export class RespuestaDto { @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) + '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: 'string', + description: 'Texto para pregunta abierta (máximo 250 caracteres)', + }, { type: 'number', description: 'ID de opción para pregunta cerrada' }, - { - type: 'array', + { + type: 'array', items: { type: 'number' }, - description: 'Array de IDs de opciones para pregunta multiple' - } - ] + 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', }) - @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 + description: 'ID del cuestionario a responder', + example: 1, }) @IsNumber() - id_formulario: number; + id_cuestionario: number; @ApiProperty({ description: 'Correo electrónico del participante', - example: 'usuario@ejemplo.com' + example: 'usuario@ejemplo.com', }) @IsString() @IsNotEmpty() - @MaxLength(250, { message: 'El correo electrónico no puede exceder 250 caracteres' }) + @MaxLength(250, { + message: 'El correo electrónico no puede exceder 250 caracteres', + }) correo: string; @ApiProperty({ @@ -64,18 +71,18 @@ export class SubmitRespuestasDto { type: [RespuestaDto], examples: [ { - id_pregunta: 101, - valor: 'Texto para pregunta abierta' + id_pregunta: 101, + valor: 'Texto para pregunta abierta', }, { id_pregunta: 102, - valor: 5 // ID de la opción para pregunta cerrada simple + valor: 5, }, { id_pregunta: 103, - valor: [2, 7, 9] // Array de IDs de opciones para pregunta de selección múltiple - } - ] + valor: [2, 7, 9], + }, + ], }) @IsArray() @ValidateNested({ each: true }) @@ -84,9 +91,9 @@ export class SubmitRespuestasDto { @ApiProperty({ description: 'Fecha y hora del envío', - example: '2025-04-02T13:45:00' + example: '2025-04-02T13:45:00', }) @IsDateString() @IsOptional() fecha_envio?: string; -} \ No newline at end of file +} diff --git a/src/cuestionario_respondido/entities/cuestionario_respondido.entity.ts b/src/cuestionario_respondido/entities/cuestionario_respondido.entity.ts index 00a3b57..1e79bc0 100644 --- a/src/cuestionario_respondido/entities/cuestionario_respondido.entity.ts +++ b/src/cuestionario_respondido/entities/cuestionario_respondido.entity.ts @@ -3,7 +3,7 @@ import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColum 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 { Participante } from 'src/participante/entities/participante.entity'; import { Opcion } from 'src/opcion/entities/opcion.entity'; @Entity('cuestionario_respondido') diff --git a/src/cuestionario_seccion/entities/cuestionario_seccion.entity.ts b/src/cuestionario_seccion/entities/cuestionario_seccion.entity.ts index 9efcd9a..4b2f99a 100644 --- a/src/cuestionario_seccion/entities/cuestionario_seccion.entity.ts +++ b/src/cuestionario_seccion/entities/cuestionario_seccion.entity.ts @@ -10,7 +10,6 @@ export class CuestionarioSeccion { @Column({ type: 'tinyint' }) posicion: number; - @ManyToOne(() => Cuestionario, (cuestionario) => cuestionario.cuestionarioSeccion) cuestionario: Cuestionario; diff --git a/src/db/typeorm.config.ts b/src/db/typeorm.config.ts new file mode 100644 index 0000000..7bef1a2 --- /dev/null +++ b/src/db/typeorm.config.ts @@ -0,0 +1,37 @@ +import { ConfigService } from '@nestjs/config'; +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; +import { RegistroAlumno } from '../alumnos/entities/registro-alumno.entity'; + +export const createMainDbConfig = async ( + configService: ConfigService, +): Promise => ({ + type: 'mariadb', + host: configService.get('DB_HOST'), + port: configService.get('DB_PORT'), + username: configService.get('DB_USERNAME'), + password: configService.get('DB_PASSWORD'), + database: configService.get('DB_DATABASE'), + entities: [__dirname + '/../**/*.entity{.ts,.js}'], + autoLoadEntities: true, + synchronize: true, + retryAttempts: 5, + retryDelay: 3000, + dropSchema: true, +}); + +export const createAlumnosDbConfig = async ( + configService: ConfigService, +): Promise => ({ + name: 'alumnosConnection', + type: 'mariadb', + host: configService.get('ALUMNOS_DB_HOST'), + port: configService.get('ALUMNOS_DB_PORT'), + username: configService.get('ALUMNOS_DB_USERNAME'), + password: configService.get('ALUMNOS_DB_PASSWORD'), + database: configService.get('ALUMNOS_DB_DATABASE'), + entities: [RegistroAlumno], + synchronize: false, + retryAttempts: 10, + retryDelay: 3000, + connectTimeout: 30000, +}); diff --git a/src/docs/swagger.config.ts b/src/docs/swagger.config.ts index 94993e1..93f50b9 100644 --- a/src/docs/swagger.config.ts +++ b/src/docs/swagger.config.ts @@ -18,11 +18,9 @@ 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('Administradores') .addTag('Cuestionarios') .addTag('Secciones') .addTag('Preguntas') diff --git a/src/emails/registro-evento.ts b/src/emails/registro-evento.ts new file mode 100644 index 0000000..1c23818 --- /dev/null +++ b/src/emails/registro-evento.ts @@ -0,0 +1,44 @@ +export function generarHtmlCorreoAsistencia({ + nombreForm, + nombreEvento, + correo, + fechaRegistro, +}: { + nombreForm: string; + nombreEvento: string; + correo: string; + fechaRegistro: string; +}) { + return ` +
+

🎓 ¡Gracias por registrarte!

+ +

+ Has completado exitosamente el formulario: ${nombreForm} +

+ +

+ Presenta este código QR (en tu celular o impreso) en cualquier stand para validar tu asistencia y recibir tu constancia. +

+ +
+ Código QR +
+ +

📋 Tus datos de registro:

+
    +
  • Evento: ${nombreEvento || 'Evento'}
  • +
  • Correo: ${correo}
  • +
  • Fecha de registro: ${fechaRegistro}
  • +
+ +

+ Si tienes alguna duda, acude al módulo de información durante el evento. +

+ +

+ Este mensaje fue enviado automáticamente. Por favor, no respondas a este correo. +

+
+ `; +} diff --git a/src/evento/docs/evento.documentation.ts b/src/evento/docs/evento.documentation.ts new file mode 100644 index 0000000..f1a9b1b --- /dev/null +++ b/src/evento/docs/evento.documentation.ts @@ -0,0 +1,68 @@ +import { applyDecorators } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBody, + ApiParam, + ApiConsumes, +} from '@nestjs/swagger'; +import { CreateEventoDto } from '../dto/create-evento.dto'; +import { UpdateEventoDto } from '../dto/update.evento.dto'; +import { + actualizacionEventoNombreYFechas, + actualizacionEventoTipo, + ejemploEventoAcademico, + ejemploEventoCultural, + ejemploFeriaSexualidad, +} from './evento.examples'; + +export class EventoApiDocumentation { + static ApiController = ApiTags('Evento'); + + static ApiCreate = applyDecorators( + ApiOperation({ summary: 'Crear un nuevo evento' }), + ApiBody({ + type: CreateEventoDto, + examples: { + ejemplo1: ejemploFeriaSexualidad, + ejemplo2: ejemploEventoAcademico, + ejemplo3: ejemploEventoCultural, + }, + }), + ); + + static ApiUpdate = applyDecorators( + ApiOperation({ summary: 'Actualizar un evento existente por ID' }), + ApiBody({ + type: UpdateEventoDto, + examples: { + actualizacion1: actualizacionEventoNombreYFechas, + actualizacion2: actualizacionEventoTipo, + }, + }), + ); + + static ApiPostBanner = applyDecorators( + ApiOperation({ summary: 'Subir banner para un evento' }), + ApiParam({ + name: 'id', + type: Number, + description: 'ID del evento al que se asigna el banner', + }), + ApiConsumes('multipart/form-data'), + ApiBody({ + schema: { + type: 'object', + properties: { + banner: { + type: 'string', + format: 'binary', + description: + 'Archivo de imagen (jpg, png, webp) que será el banner del evento', + }, + }, + required: ['banner'], + }, + }), + ); +} diff --git a/src/evento/docs/evento.examples.ts b/src/evento/docs/evento.examples.ts new file mode 100644 index 0000000..a94cb2b --- /dev/null +++ b/src/evento/docs/evento.examples.ts @@ -0,0 +1,57 @@ +/* CREATE */ + +export const ejemploFeriaSexualidad = { + summary: 'Evento que simula una feria', + description: + 'Un evento de una feria de la sexualidad, con registro para la comunidad universitaria', + value: { + tipo_evento: 'Estudiantil', + nombre_evento: 'Feria de la Sexualidad 2026-3', + descripcion_evento: + '**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.', + fecha_inicio: '2025-07-10T09:00:00.000Z', + fecha_fin: '2025-07-11T17:00:00.000Z', + }, +}; + +export const ejemploEventoAcademico = { + summary: 'Evento académico', + description: 'Un evento tipo seminario académico de dos días', + value: { + tipo_evento: 'Académico', + nombre_evento: 'Seminario de Innovación Educativa', + fecha_inicio: '2025-07-10T09:00:00.000Z', + fecha_fin: '2025-07-11T17:00:00.000Z', + }, +}; + +export const ejemploEventoCultural = { + summary: 'Evento cultural', + description: 'Un evento cultural de un solo día', + value: { + tipo_evento: 'Cultural', + nombre_evento: 'Festival de Música y Arte', + fecha_inicio: '2025-08-05T15:00:00.000Z', + fecha_fin: '2025-08-05T22:00:00.000Z', + }, +}; + +/* UPDATE */ + +export const actualizacionEventoNombreYFechas = { + summary: 'Actualizar nombre y fechas', + description: 'Se cambia el nombre del evento y se ajustan las fechas', + value: { + nombre_evento: 'Seminario de Tecnología Educativa', + fecha_inicio: '2025-07-15T10:00:00.000Z', + fecha_fin: '2025-07-16T18:00:00.000Z', + }, +}; + +export const actualizacionEventoTipo = { + summary: 'Solo cambiar tipo de evento', + description: 'Se actualiza el tipo sin modificar lo demás', + value: { + tipo_evento: 'Deportivo', + }, +}; diff --git a/src/evento/dto/create-evento.dto.ts b/src/evento/dto/create-evento.dto.ts index 5fa9fd6..b5e6986 100644 --- a/src/evento/dto/create-evento.dto.ts +++ b/src/evento/dto/create-evento.dto.ts @@ -1,11 +1,26 @@ +import { IsString, IsNotEmpty, IsDate, IsOptional } from 'class-validator'; +import { Type } from 'class-transformer'; + export class CreateEventoDto { - tipo_evento: string; + @IsString() + @IsNotEmpty({ message: 'El tipo de evento es obligatorio.' }) + tipo_evento: string; - nombre_evento: string; + @IsString() + @IsNotEmpty({ message: 'El nombre del evento es obligatorio.' }) + nombre_evento: string; - fecha_inicio: Date; + @IsOptional() + @IsNotEmpty({ message: 'La descripción del evento es obligatoria.' }) + descripcion_evento: string; - fecha_fin: Date; + @Type(() => Date) + @IsDate({ message: 'La fecha de inicio debe ser una fecha válida.' }) + @IsNotEmpty({ message: 'La fecha de inicio es obligatoria.' }) + fecha_inicio: Date; - //agregar el id del administrador -} \ No newline at end of file + @Type(() => Date) + @IsDate({ message: 'La fecha de fin debe ser una fecha válida.' }) + @IsNotEmpty({ message: 'La fecha de fin es obligatoria.' }) + fecha_fin: Date; +} diff --git a/src/evento/dto/update.evento.dto.ts b/src/evento/dto/update.evento.dto.ts index f85579b..8f89f69 100644 --- a/src/evento/dto/update.evento.dto.ts +++ b/src/evento/dto/update.evento.dto.ts @@ -1,6 +1,23 @@ +import { IsOptional, IsString, IsNotEmpty, IsDate } from 'class-validator'; +import { Type } from 'class-transformer'; + export class UpdateEventoDto { - tipo_evento?: string - nombre_evento: string - fecha_inicio?: Date - fecha_fin?: Date -} \ No newline at end of file + @IsOptional() + @IsString() + @IsNotEmpty({ message: 'El tipo de evento no puede estar vacío si se proporciona' }) + tipo_evento?: string; + + @IsString() + @IsNotEmpty({ message: 'El nombre del evento es obligatorio' }) + nombre_evento: string; + + @IsOptional() + @Type(() => Date) + @IsDate({ message: 'La fecha de inicio debe ser una fecha válida' }) + fecha_inicio?: Date; + + @IsOptional() + @Type(() => Date) + @IsDate({ message: 'La fecha de fin debe ser una fecha válida' }) + fecha_fin?: Date; +} diff --git a/src/evento/entities/evento.entity.ts b/src/evento/entities/evento.entity.ts new file mode 100644 index 0000000..a86530e --- /dev/null +++ b/src/evento/entities/evento.entity.ts @@ -0,0 +1,37 @@ +import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity'; +import { + Column, + Entity, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity() +export class Evento { + @PrimaryGeneratedColumn() + id_evento: number; + + @Column({ length: 50 }) + tipo_evento: string; + + @Column({ length: 200 }) + nombre_evento: string; + + @Column({ length: 500, nullable: true }) + descripcion_evento: string; + + @Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + fecha_inicio: Date; + + @Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + fecha_fin: Date; + + @Column({ nullable: true }) + asistencias: number; + + @Column({ nullable: true }) + banner: string; + + @OneToMany(() => Cuestionario, (cuestionario) => cuestionario.evento) + cuestionarios: Cuestionario[]; +} diff --git a/src/evento/evento.controller.ts b/src/evento/evento.controller.ts index 0586840..3592062 100644 --- a/src/evento/evento.controller.ts +++ b/src/evento/evento.controller.ts @@ -1,37 +1,101 @@ -import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; import { EventoService } from './evento.service'; -import { Evento } from './evento.entity'; +import { Evento } from './entities/evento.entity'; import { CreateEventoDto } from './dto/create-evento.dto'; import { UpdateEventoDto } from './dto/update.evento.dto'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +import { extname } from 'path'; +import { EventoApiDocumentation } from './docs/evento.documentation'; @Controller('evento') +@EventoApiDocumentation.ApiController export class EventoController { - constructor(private eventoService: EventoService) {} + constructor(private eventoService: EventoService) {} - @Get() - getEventos(): Promise { - return this.eventoService.getEventos() + @Post() + @EventoApiDocumentation.ApiCreate + createEvento(@Body() newEvento: CreateEventoDto) { + return this.eventoService.createEvento(newEvento); + } + + @Get() + getEventos(): Promise { + return this.eventoService.getEventos(); + } + + @Get(':id/cuestionarios') + getCuestionarios(@Param('id', ParseIntPipe) id: number) { + return this.eventoService.getCuestionariosEvento(id); + } + + @Get('activos') + getEventosActivos(): Promise { + return this.eventoService.getEventosActivos(); + } + + @Post(':id/banner') + @UseInterceptors( + FileInterceptor('banner', { + storage: diskStorage({ + destination: './public/banners', // Asegúrate de que esta carpeta exista + filename: (req, file, cb) => { + const uniqueSuffix = + Date.now() + '-' + Math.round(Math.random() * 1e9); + const ext = extname(file.originalname); + cb(null, `banner-${uniqueSuffix}${ext}`); + }, + }), + fileFilter: (req, file, cb) => { + const allowedTypes = /jpeg|jpg|png|webp/; + const ext = extname(file.originalname).toLowerCase(); + if (allowedTypes.test(ext)) { + cb(null, true); + } else { + cb(new Error('Tipo de archivo no permitido'), false); + } + }, + }), + ) + @EventoApiDocumentation.ApiPostBanner + async subirBannerEvento( + @Param('id', ParseIntPipe) id: number, + @UploadedFile() file: Express.Multer.File, + ) { + if (!file) { + throw new Error('No se ha recibido un archivo válido'); } - @Get(':id') - getEvento(@Param('id', ParseIntPipe) id: number) { - return this.eventoService.getEvento(id) - } + return this.eventoService.asociarBanner(id, file.filename); + } - @Post() - createEvento(@Body() newEvento: CreateEventoDto) { - return this.eventoService.createEvento(newEvento) - } + @Get(':id') + getEvento(@Param('id', ParseIntPipe) id: number) { + return this.eventoService.getEvento(id); + } - @Delete(':id') - deleteEvento(@Param('id', ParseIntPipe) id: number) { - return this.eventoService.deleteEvento(id) - } - //@Param(':id', ParseIntPipe) id: number, @Body() tipoUser: UpdateTipoUserDto - @Patch() - updateEvento(@Param(':id', ParseIntPipe) id: number, @Body() evento: UpdateEventoDto) { - return this.eventoService.updateEvento(id, evento) - } + @Delete(':id') + deleteEvento(@Param('id', ParseIntPipe) id: number) { + return this.eventoService.deleteEvento(id); + } - + @Patch(':id') + @EventoApiDocumentation.ApiUpdate + updateEvento( + @Param('id', ParseIntPipe) id: number, + @Body() evento: UpdateEventoDto, + ) { + return this.eventoService.updateEvento(id, evento); + } } diff --git a/src/evento/evento.entity.ts b/src/evento/evento.entity.ts deleted file mode 100644 index 046a042..0000000 --- a/src/evento/evento.entity.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Participante } from "src/participante/participante.entity"; -import { ParticipanteEvento } from "src/participante_evento/participante_evento.entity"; -import { Column, Entity, ManyToMany, OneToMany, PrimaryGeneratedColumn } from "typeorm"; - -@Entity() -export class Evento { - @PrimaryGeneratedColumn() - id_evento: number - - @Column() - tipo_evento: string - - @Column() - nombre_evento: string - - @Column({type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) - fecha_inicio: Date - - @Column({type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) - fecha_fin: Date - - /* falta hacer la relacion - @Column() - id_administrador: number - - @ManyToOne(() => Administrador, (admin) => admin.eventos) - @JoinColumn({ name: "id_administrador" }) - administrador: Administrador; - - - - @OneToMany(() => Asistencia, (asistencia) => asistencia.evento) - asistencias: Asistencia[]; - */ - - - - @OneToMany(() => ParticipanteEvento, participanteEvento => participanteEvento.evento) - participanteEventos: ParticipanteEvento[]; - -} \ No newline at end of file diff --git a/src/evento/evento.module.ts b/src/evento/evento.module.ts index 58e904d..9335a40 100644 --- a/src/evento/evento.module.ts +++ b/src/evento/evento.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { EventoService } from './evento.service'; import { EventoController } from './evento.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Evento } from './evento.entity'; +import { Evento } from './entities/evento.entity'; @Module({ imports: [TypeOrmModule.forFeature([Evento])], diff --git a/src/evento/evento.service.ts b/src/evento/evento.service.ts index eaf5d9f..47d942f 100644 --- a/src/evento/evento.service.ts +++ b/src/evento/evento.service.ts @@ -1,73 +1,116 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { + HttpException, + HttpStatus, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { Evento } from './evento.entity'; +import { MoreThan, Repository } from 'typeorm'; +import { Evento } from './entities/evento.entity'; import { CreateEventoDto } from './dto/create-evento.dto'; import { UpdateEventoDto } from './dto/update.evento.dto'; @Injectable() export class EventoService { - constructor( - @InjectRepository(Evento) private eventoRepository: Repository, - ) {} + constructor( + @InjectRepository(Evento) private eventoRepository: Repository, + ) {} - async createEvento(evento: CreateEventoDto) { - const eventoFound = await this.eventoRepository.findOne({ - where: { - nombre_evento: evento.nombre_evento, - tipo_evento: evento.tipo_evento - } - }) + async createEvento(evento: CreateEventoDto) { + const eventoFound = await this.eventoRepository.findOne({ + where: { + nombre_evento: evento.nombre_evento, + tipo_evento: evento.tipo_evento, + }, + }); - if (eventoFound) - return new HttpException('Evento already exists', HttpStatus.CONFLICT) - - const createEvento = this.eventoRepository.create(evento) + if (eventoFound) + throw new HttpException( + 'Ya existe un evento con el mismo nombre y tipo, puede agregar el periodo al nombre del evento para diferenciarlos', + HttpStatus.BAD_REQUEST, + ); - return this.eventoRepository.save(createEvento) + const createEvento = this.eventoRepository.create(evento); + + return this.eventoRepository.save(createEvento); + } + + // evento.service.ts + + async asociarBanner(id: number, filename: string) { + const evento = await this.getEventoOrFail(id); + + evento.banner = filename; + return this.eventoRepository.save(evento); + } + + getEventos() { + return this.eventoRepository.find(); + } + + async getCuestionariosEvento(id_evento: number) { + const evento = await this.eventoRepository.findOne({ + where: { id_evento }, + relations: ['cuestionarios'], // solo los cuestionarios, sin secciones + }); + + if (!evento) { + throw new NotFoundException(`Evento con ID ${id_evento} no encontrado.`); } - getEventos() { - return this.eventoRepository.find({ - relations: ['participantes'] - }) + return evento; + } + + async getEvento(id_evento: number) { + return await this.eventoRepository.findOne({ + where: { + id_evento, + }, + }); + } + + async getEventoOrFail(id_evento: number): Promise { + const eventoFound = await this.eventoRepository.findOne({ + where: { + id_evento, + }, + }); + + if (!eventoFound) { + throw new HttpException( + 'El evento buscado no existe', + HttpStatus.NOT_FOUND, + ); } - async getEvento(id_evento: number) { - const eventoFound = await this.eventoRepository.findOne({ - where: { - id_evento - }, - relations: ['participantes'] - }) + return eventoFound; + } - if (!eventoFound) - return new HttpException('Evento not found', HttpStatus.NOT_FOUND); + async deleteEvento(id_evento: number) { + await this.getEventoOrFail(id_evento); - return eventoFound + const result = await this.eventoRepository.delete(id_evento); + + if (result.affected === 0) { + throw new HttpException('Evento not found', HttpStatus.NOT_FOUND); } - async deleteEvento(id_evento: number) { - const result = await this.eventoRepository.delete({ id_evento }) + return { message: 'Evento eliminado exitosamente' }; + } - if (result.affected === 0) { - return new HttpException('Evento not found', HttpStatus.NOT_FOUND); - } + async updateEvento(id_evento: number, evento: UpdateEventoDto) { + const eventoFound = await this.getEventoOrFail(id_evento); - return result - } + const updateEvento = Object.assign(eventoFound, evento); + return this.eventoRepository.save(updateEvento); + } - async updateEvento(id_evento: number, evento: UpdateEventoDto) { - const eventoFound = await this.eventoRepository.findOne({ - where: { - id_evento - } - }) - - if (!eventoFound) - return new HttpException('Evento not found', HttpStatus.NOT_FOUND) - - const updateEvento = Object.assign(eventoFound, evento) - return this.eventoRepository.save(updateEvento) - } + async getEventosActivos() { + const now = new Date(); + return this.eventoRepository.find({ + where: { + fecha_fin: MoreThan(now), + }, + }); + } } diff --git a/src/main.ts b/src/main.ts index bdbcf72..1e616e4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,23 +1,22 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; -import 'dotenv/config'; -import { DocsModule } from './docs/docs.module'; -import { DataSource } from 'typeorm'; -import { TipoPregunta } from './tipo_pregunta/entities/tipo_pregunta.entity'; import { ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; async function bootstrap() { const app = await NestFactory.create(AppModule); - + const configService = app.get(ConfigService); + // Configuración de validación global app.useGlobalPipes( new ValidationPipe({ whitelist: true, // Elimina propiedades no decoradas forbidNonWhitelisted: true, // Arroja error si hay propiedades no decoradas transform: true, // Transforma los datos recibidos al tipo definido en el DTO - }) + }), ); - + // CORS configuration app.enableCors({ origin: '*', @@ -25,34 +24,30 @@ async function bootstrap() { allowedHeaders: ['Content-Type', 'Authorization'], }); - // Swagger setup - DocsModule.setupSwagger(app); + const config = new DocumentBuilder() + .setTitle('Formularios API') + .setDescription( + `Esta es la API del sistema de registros para eventos de la FES Acatlán. Permite gestionar eventos académicos y sus formularios asociados. - // Seed TipoPregunta data - const dataSource = app.get(DataSource); - const tiposPreguntaRepository = dataSource.getRepository(TipoPregunta); - - // Check if data exists - const count = await tiposPreguntaRepository.count(); - - if (count === 0) { - // Create default tipos de pregunta - const tiposPregunta = [ - { tipo_pregunta: 'Texto' }, - { tipo_pregunta: 'Numero' }, - { tipo_pregunta: 'Radio' }, - { tipo_pregunta: 'Multiple' }, - { tipo_pregunta: 'Abierto' }, - { tipo_pregunta: 'Fecha' }, - ]; - - await tiposPreguntaRepository.save(tiposPregunta); - console.log('Tipos de pregunta creados'); - } +Flujo de creación: + +1. Primero, se debe crear un evento usando **POST /evento**. +2. Luego, se crea un formulario y se relaciona con el evento usando **POST /cuestionario**. +3. (Opcional) También se puede crear un formulario y un evento al mismo tiempo usando **POST /cuestionario/evento**, lo cual genera automáticamente ambos y los relaciona.`, + ) + .setVersion('1.0') + .addBearerAuth() + .addTag('Evento') + .addTag('Cuestionario') + .addTag('Cuestionario Respondido') + .build(); + + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api-docs', app, document); // Start the server - await app.listen(process.env.PORT ?? 4200); - console.log('API en ejecución en http://localhost:4200'); + await app.listen(configService.get('API_PORT') || 4200); + console.log('\nAPI en ejecución en http://localhost:4200'); console.log('Swagger disponible en http://localhost:4200/api-docs'); } bootstrap(); diff --git a/src/opcion/entities/opcion.entity.ts b/src/opcion/entities/opcion.entity.ts index 3523e76..bfe2c0e 100644 --- a/src/opcion/entities/opcion.entity.ts +++ b/src/opcion/entities/opcion.entity.ts @@ -1,5 +1,5 @@ -import { PreguntaOpcion } from "src/pregunta_opcion/entities/pregunta_opcion.entity"; -import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm"; +import { PreguntaOpcion } from 'src/pregunta_opcion/entities/pregunta_opcion.entity'; +import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class Opcion { @@ -11,6 +11,4 @@ export class Opcion { @OneToMany(() => PreguntaOpcion, (preguntaOpcion) => preguntaOpcion.opcion) preguntasOpciones: PreguntaOpcion[]; - - -} \ No newline at end of file +} diff --git a/src/participante/dto/create-participante.dto.ts b/src/participante/dto/create-participante.dto.ts index 871599b..328bf95 100644 --- a/src/participante/dto/create-participante.dto.ts +++ b/src/participante/dto/create-participante.dto.ts @@ -10,13 +10,4 @@ export class CreateParticipanteDto { @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; } \ No newline at end of file diff --git a/src/participante/entities/participante.entity.ts b/src/participante/entities/participante.entity.ts new file mode 100644 index 0000000..f45c999 --- /dev/null +++ b/src/participante/entities/participante.entity.ts @@ -0,0 +1,31 @@ +import { CuestionarioRespondido } from 'src/cuestionario_respondido/entities/cuestionario_respondido.entity'; +import { ParticipanteEvento } from 'src/participante_evento/entities/participante_evento.entity'; +import { + Column, + Entity, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity() +export class Participante { + @PrimaryGeneratedColumn() + id_participante: number; + + @Column({ unique: true }) + correo: string; + + // Cuestionarios que ha respondido + @OneToMany( + () => CuestionarioRespondido, + (cuestionarioRespondido) => cuestionarioRespondido.participante, + ) + cuestionariosRespondidos: CuestionarioRespondido[]; + + // Cuestionarios a los que se ha inscrito (subeventos) + @OneToMany( + () => ParticipanteEvento, + (participanteEvento) => participanteEvento.participante, + ) + participanteEventos: ParticipanteEvento[]; +} diff --git a/src/participante/participante.controller.ts b/src/participante/participante.controller.ts index e25b387..8abd9a6 100644 --- a/src/participante/participante.controller.ts +++ b/src/participante/participante.controller.ts @@ -1,5 +1,5 @@ import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common'; -import { Participante } from './participante.entity'; +import { Participante } from './entities/participante.entity'; import { ParticipanteService } from './participante.service'; import { CreateParticipanteDto } from './dto/create-participante.dto'; import { UpdateParticipanteDto } from './dto/update.participante.dto'; diff --git a/src/participante/participante.entity.ts b/src/participante/participante.entity.ts deleted file mode 100644 index 3579682..0000000 --- a/src/participante/participante.entity.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { CuestionarioRespondido } from "src/cuestionario_respondido/entities/cuestionario_respondido.entity"; -import { Evento } from "src/evento/evento.entity"; -import { ParticipanteEvento } from "src/participante_evento/participante_evento.entity"; -import { TipoUser } from "src/tipo_user/tipo_user.entity"; -import { Column, Entity, JoinColumn, JoinTable, ManyToMany, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm"; - -@Entity() -export class Participante { - @PrimaryGeneratedColumn() - id_participante: number - - @Column() - correo: string - - @Column() - id_tipo_user: number - - //Relacion con tipo usuario - @ManyToOne(() => TipoUser, tipoUser => tipoUser.participante) - @JoinColumn({ name: 'participante_id' }) //nombre de la relacion - tipo_user: TipoUser - - /* - @ManyToOne(() => Administrador, (admin) => admin.eventos) - @JoinColumn({ name: "id_administrador" }) - administrador: Administrador; - - @ManyToMany(() => Evento, evento => evento.participantes) - @JoinTable({ - name: "participante_evento", - joinColumn: { name: "id_participante", referencedColumnName: "id_participante" }, - inverseJoinColumn: { name: "id_evento", referencedColumnName: "id_evento" } - }) - eventos: Evento[]; - - @OneToMany(() => Asistencia, (asistencia) => asistencia.evento) - asistencias: Asistencia[]; - */ - - - - @OneToMany(() => CuestionarioRespondido, cuestionario => cuestionario.participante) - cuestionariosRespondidos: CuestionarioRespondido[]; - - @OneToMany(() => ParticipanteEvento, participanteEvento => participanteEvento.participante) - participanteEventos: ParticipanteEvento[]; - - -} \ No newline at end of file diff --git a/src/participante/participante.module.ts b/src/participante/participante.module.ts index 41a0dd0..945f59e 100644 --- a/src/participante/participante.module.ts +++ b/src/participante/participante.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { ParticipanteService } from './participante.service'; import { ParticipanteController } from './participante.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Participante } from './participante.entity'; +import { Participante } from './entities/participante.entity'; @Module({ imports: [TypeOrmModule.forFeature([Participante])], diff --git a/src/participante/participante.service.ts b/src/participante/participante.service.ts index c500033..52c0df6 100644 --- a/src/participante/participante.service.ts +++ b/src/participante/participante.service.ts @@ -1,124 +1,180 @@ -import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Participante } from './participante.entity'; +import { Participante } from './entities/participante.entity'; import { Repository } from 'typeorm'; import { CreateParticipanteDto } from './dto/create-participante.dto'; import { UpdateParticipanteDto } from './dto/update.participante.dto'; @Injectable() export class ParticipanteService { - constructor( - @InjectRepository(Participante) private participanteRepository: Repository - ) {} + constructor( + @InjectRepository(Participante) + private participanteRepository: Repository, + ) {} - /** - * 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 { - // Verificar si ya existe un participante con el mismo correo - const participanteFound = await this.participanteRepository.findOne({ - where: { - correo: participante.correo - } - }); + /** + * 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 { + // Verificar si ya existe un participante con el mismo correo + const participanteFound = await this.participanteRepository.findOne({ + where: { + correo: participante.correo, + }, + }); - if (participanteFound) { - throw new ConflictException(`Ya existe un participante con el correo ${participante.correo}`); - } - - const nuevoParticipante = this.participanteRepository.create(participante); - return this.participanteRepository.save(nuevoParticipante); + if (participanteFound) { + throw new ConflictException( + `Ya existe un participante con el correo ${participante.correo}`, + ); } - /** - * Obtiene todos los participantes - * @returns Lista de participantes con sus relaciones - */ - async getParticipantes(): Promise { - return this.participanteRepository.find({ - relations: ['tipo_user', 'participanteEventos'] - }); + const nuevoParticipante = this.participanteRepository.create(participante); + return this.participanteRepository.save(nuevoParticipante); + } + + /** + * Obtiene todos los participantes + * @returns Lista de participantes con sus relaciones + */ + async getParticipantes(): Promise { + return this.participanteRepository.find({ + relations: ['tipo_user', 'participanteEventos'], + }); + } + + /** + * 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 { + const participanteFound = await this.participanteRepository.findOne({ + where: { + id_participante, + }, + relations: ['tipo_user', 'participanteEventos'], + }); + + if (!participanteFound) { + throw new NotFoundException( + `Participante con ID ${id_participante} no encontrado`, + ); } - /** - * 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 { - const participanteFound = await this.participanteRepository.findOne({ - where: { - id_participante - }, - relations: ['tipo_user', 'participanteEventos'] - }); + return participanteFound; + } - if (!participanteFound) { - throw new NotFoundException(`Participante con ID ${id_participante} no encontrado`); - } - - return participanteFound; + async getParticipanteByCorreo(correo: string): Promise { + const participanteFound = await this.participanteRepository.findOne({ + where: { + correo, + }, + relations: ['tipo_user', 'participanteEventos'], + }); + return participanteFound; + } + + async getParticipanteByCorreoOrFail(correo: string): Promise { + const participanteFound = await this.participanteRepository.findOne({ + where: { + correo, + }, + relations: ['tipo_user', 'participanteEventos'], + }); + + if (!participanteFound) { + throw new NotFoundException( + `Participante con correo ${correo} no encontrado`, + ); } - /** - * 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 }); + return participanteFound; + } - if (result.affected === 0) { - throw new NotFoundException(`Participante con ID ${id_participante} no encontrado`); - } + /** + * 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, + }); - return { - success: true, - message: `Participante con ID ${id_participante} eliminado correctamente`, - affected: result.affected - }; + if (result.affected === 0) { + throw new NotFoundException( + `Participante con ID ${id_participante} no encontrado`, + ); } - /** - * 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 { - // Verificar si el participante existe - const participanteFound = await this.participanteRepository.findOne({ - where: { - id_participante - } - }); + return { + success: true, + message: `Participante con ID ${id_participante} eliminado correctamente`, + affected: result.affected, + }; + } - if (!participanteFound) { - throw new NotFoundException(`Participante con ID ${id_participante} no encontrado`); - } + /** + * 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 { + // Verificar si el participante existe + const participanteFound = await this.participanteRepository.findOne({ + where: { + id_participante, + }, + }); - // 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); + if (!participanteFound) { + throw new NotFoundException( + `Participante con ID ${id_participante} no encontrado`, + ); } + + // 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); + } } diff --git a/src/participante_evento/entities/participante_evento.entity.ts b/src/participante_evento/entities/participante_evento.entity.ts new file mode 100644 index 0000000..d0b5a57 --- /dev/null +++ b/src/participante_evento/entities/participante_evento.entity.ts @@ -0,0 +1,40 @@ +import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity'; +import { Evento } from 'src/evento/entities/evento.entity'; +import { Participante } from 'src/participante/entities/participante.entity'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity() +export class ParticipanteEvento { + @PrimaryGeneratedColumn() + id_participante_evento: number; + + @Column() + id_participante: number; + + @Column() + id_cuestionario: number; + + @Column({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + fecha_registro: Date; + + @Column({ type: 'datetime', nullable: true }) + fecha_asistencia: Date; + + @Column({ type: 'boolean', default: false }) + asistio: boolean; + + // Relaciones + @ManyToOne(() => Participante, (p) => p.participanteEventos) + @JoinColumn({ name: 'id_participante' }) + participante: Participante; + + @ManyToOne(() => Cuestionario, (c) => c.inscripciones) + @JoinColumn({ name: 'id_cuestionario' }) + cuestionario: Cuestionario; +} diff --git a/src/participante_evento/participante_evento.controller.ts b/src/participante_evento/participante_evento.controller.ts index 5b78ac4..5e6b26c 100644 --- a/src/participante_evento/participante_evento.controller.ts +++ b/src/participante_evento/participante_evento.controller.ts @@ -1,6 +1,15 @@ -import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, +} from '@nestjs/common'; import { ParticipanteEventoService } from './participante_evento.service'; -import { ParticipanteEvento } from './participante_evento.entity'; +import { ParticipanteEvento } from './entities/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'; @@ -8,179 +17,238 @@ import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; @ApiTags('Participantes-Eventos') @Controller('participante-evento') export class ParticipanteEventoController { + constructor(private participanteEventoService: ParticipanteEventoService) {} - constructor(private participanteEventoService: ParticipanteEventoService) {} + @ApiOperation({ summary: 'Obtener participantes por evento' }) + @ApiParam({ name: 'id_cuestionario', 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/:id_cuestionario') + getParticipantesPorEvento( + @Param('id_cuestionario', ParseIntPipe) id_cuestionario: number, + ) { + return this.participanteEventoService.getParticipantesPorCuestionario( + id_cuestionario, + ); + } - @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 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 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 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 { - return this.participanteEventoService.getParticipantesEvento() - } + @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 { + 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 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: '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: '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) - } + @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, + ); + } } diff --git a/src/participante_evento/participante_evento.entity.ts b/src/participante_evento/participante_evento.entity.ts deleted file mode 100644 index 4f537d3..0000000 --- a/src/participante_evento/participante_evento.entity.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Evento } from "src/evento/evento.entity"; -import { Participante } from "src/participante/participante.entity"; -import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn, PrimaryGeneratedColumn } from "typeorm"; - -@Entity() -export class ParticipanteEvento { - @PrimaryColumn() - id_participante: number - @PrimaryColumn() - id_evento: number - - @Column() - fecha_inscripcion: Date - - @Column() - estatus: boolean - - @Column({ nullable: true, type: 'datetime' }) - fecha_asistencia: Date - - /* - @OneToOne(() => Qr, (qr) => qr.participanteEvento) - qr: Qr; - - @OneToMany(() => ParticipanteEvento, (pe) => pe.evento) - participantes: ParticipanteEvento[]; - - @ManyToOne(() => Evento, (evento) => evento.participantes) - @JoinColumn({ name: "id_evento" }) - evento: Evento; - */ - - @ManyToOne(() => Participante, participante => participante.participanteEventos) - @JoinColumn({ name: "id_participante" }) - participante: Participante; - - @ManyToOne(() => Evento, evento => evento.participanteEventos) - @JoinColumn({ name: "id_evento" }) - evento: Evento; - -} \ No newline at end of file diff --git a/src/participante_evento/participante_evento.module.ts b/src/participante_evento/participante_evento.module.ts index aa7ba57..3a957f4 100644 --- a/src/participante_evento/participante_evento.module.ts +++ b/src/participante_evento/participante_evento.module.ts @@ -2,13 +2,17 @@ import { Module } from '@nestjs/common'; import { ParticipanteEventoService } from './participante_evento.service'; import { ParticipanteEventoController } from './participante_evento.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { ParticipanteEvento } from './participante_evento.entity'; -import { Evento } from 'src/evento/evento.entity'; -import { Participante } from 'src/participante/participante.entity'; +import { ParticipanteEvento } from './entities/participante_evento.entity'; +import { Evento } from 'src/evento/entities/evento.entity'; +import { Participante } from 'src/participante/entities/participante.entity'; +import { CuestionarioModule } from 'src/cuestionario/cuestionario.module'; @Module({ - imports: [TypeOrmModule.forFeature([ParticipanteEvento, Evento, Participante])], - controllers: [ParticipanteEventoController], + imports: [ + TypeOrmModule.forFeature([ParticipanteEvento, Evento, Participante]), + CuestionarioModule, + ], + controllers: [ParticipanteEventoController], providers: [ParticipanteEventoService], exports: [ParticipanteEventoService], }) diff --git a/src/participante_evento/participante_evento.service.ts b/src/participante_evento/participante_evento.service.ts index 77f9f5d..f7808c5 100644 --- a/src/participante_evento/participante_evento.service.ts +++ b/src/participante_evento/participante_evento.service.ts @@ -1,160 +1,179 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { ParticipanteEvento } from './participante_evento.entity'; +import { ParticipanteEvento } from './entities/participante_evento.entity'; import { Repository } from 'typeorm'; import { CreateParticipanteEventoDto } from './dto/create-participante_evento.dto'; import { UpdateParticipanteEventoDto } from './dto/update.participante_evento.dto'; -import { Evento } from 'src/evento/evento.entity'; -import { Participante } from 'src/participante/participante.entity'; +import { Evento } from 'src/evento/entities/evento.entity'; +import { Participante } from 'src/participante/entities/participante.entity'; +import { CuestionarioService } from 'src/cuestionario/cuestionario.service'; @Injectable() export class ParticipanteEventoService { + constructor( + @InjectRepository(ParticipanteEvento) + private participanteEventoRepository: Repository, + @InjectRepository(Evento) private eventoRepository: Repository, + @InjectRepository(Participante) + private participanteRepository: Repository, + private readonly cuestionarioService: CuestionarioService, + ) {} - constructor( - @InjectRepository(ParticipanteEvento) private participanteEventoRepository: Repository, - @InjectRepository(Evento) private eventoRepository: Repository, - @InjectRepository(Participante) private participanteRepository: Repository - ) {} + async createParticipanteEvento( + participanteEvento: CreateParticipanteEventoDto, + ) { + const participante_eventoFound = + await this.participanteEventoRepository.findOne({ + where: { + id_participante: participanteEvento.id_participante, + id_cuestionario: participanteEvento.id_evento, + }, + }); - async createParticipanteEvento(participanteEvento: CreateParticipanteEventoDto) { - - const participante_eventoFound = await this.participanteEventoRepository.findOne({ - where: { - id_participante: participanteEvento.id_participante, - id_evento: participanteEvento.id_evento - } - }) - - if (participante_eventoFound) { - return new HttpException('Participante already exists in this event', HttpStatus.CONFLICT) - } - - return this.participanteEventoRepository.save(participanteEvento) + if (participante_eventoFound) { + return new HttpException( + 'Participante already exists in this event', + HttpStatus.CONFLICT, + ); } - getParticipantesEvento() { - return this.participanteEventoRepository.find({ - relations: ['evento', 'participante'] - }) + return this.participanteEventoRepository.save(participanteEvento); + } + + getParticipantesEvento() { + return this.participanteEventoRepository.find({ + relations: ['evento', 'participante'], + }); + } + + async getParticipanteEvento(id_participante: number) { + const participante_eventoFound = + await this.participanteEventoRepository.findOne({ + where: { + id_participante, + }, + relations: ['evento', 'participante'], + }); + + if (!participante_eventoFound) { + return new HttpException('Participante not found', HttpStatus.NOT_FOUND); } - async getParticipanteEvento(id_participante: number) { - const participante_eventoFound = await this.participanteEventoRepository.findOne({ - where: { - id_participante - }, - relations: ['evento', 'participante'] - }) + return participante_eventoFound; + } - if (!participante_eventoFound) { - return new HttpException('Participante not found', HttpStatus.NOT_FOUND); - } + async getParticipanteEventoEspecifico( + id_participante: number, + id_evento: number, + ) { + const participante_eventoFound = + await this.participanteEventoRepository.findOne({ + where: { + id_participante, + id_cuestionario: id_evento, + }, + relations: ['evento', 'participante'], + }); - return participante_eventoFound + if (!participante_eventoFound) { + return new HttpException('Registro no encontrado', HttpStatus.NOT_FOUND); } - async getParticipanteEventoEspecifico(id_participante: number, id_evento: number) { - const participante_eventoFound = await this.participanteEventoRepository.findOne({ - where: { - id_participante, - id_evento - }, - relations: ['evento', 'participante'] - }) + return participante_eventoFound; + } - if (!participante_eventoFound) { - return new HttpException('Registro no encontrado', HttpStatus.NOT_FOUND); - } + /** + * 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 getParticipantesPorCuestionario(id_cuestionario: number) { + const cuestionario = + await this.cuestionarioService.getCuestionarioOrFail(id_cuestionario); + console.log(cuestionario); - return participante_eventoFound + const participantesEvento = await this.participanteEventoRepository.find({ + where: { id_cuestionario }, + 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, + ); } - /** - * 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 } - }); + // Obtener todos los registros participante-evento para este participante + const eventosParticipante = await this.participanteEventoRepository.find({ + where: { id_participante }, + relations: ['evento'], + }); - if (!eventoExists) { - throw new HttpException(`Evento con ID ${id_evento} no encontrado`, HttpStatus.NOT_FOUND); - } + return eventosParticipante; + } - // Obtener todos los registros participante-evento para este evento - const participantesEvento = await this.participanteEventoRepository.find({ - where: { id_evento }, - relations: ['participante'] - }); + async deleteParticipanteEvento(id_participante: number) { + const result = await this.participanteEventoRepository.delete({ + id_participante, + }); - return participantesEvento; + if (result.affected === 0) { + return new HttpException('Participante not found', HttpStatus.NOT_FOUND); } - /** - * 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 } - }); + return result; + } - if (!participanteExists) { - throw new HttpException(`Participante con ID ${id_participante} no encontrado`, HttpStatus.NOT_FOUND); - } + async updateParticipanteEvento( + id_participante: number, + id_evento: number, + participante_evento: UpdateParticipanteEventoDto, + ) { + const participante_eventoFound = + await this.participanteEventoRepository.findOne({ + where: { + id_participante, + id_cuestionario: id_evento, + }, + }); - // Obtener todos los registros participante-evento para este participante - const eventosParticipante = await this.participanteEventoRepository.find({ - where: { id_participante }, - relations: ['evento'] - }); - - return eventosParticipante; + if (!participante_eventoFound) { + return new HttpException('Participante not found', HttpStatus.NOT_FOUND); } - async deleteParticipanteEvento(id_participante: number) { - const result = await this.participanteEventoRepository.delete({ id_participante }) + return this.participanteEventoRepository.save(participante_evento); + } - if (result.affected === 0) { - return new HttpException('Participante not found', HttpStatus.NOT_FOUND); - } + async registrarAsistencia(id_participante: number, id_cuestionario: number) { + const participante_eventoFound = + await this.participanteEventoRepository.findOne({ + where: { + id_participante, + cuestionario: { id_cuestionario }, + }, + }); - return result + if (!participante_eventoFound) { + return new HttpException('Registro no encontrado', HttpStatus.NOT_FOUND); } - async updateParticipanteEvento(id_participante: number, id_evento: number, participante_evento: UpdateParticipanteEventoDto) { - const participante_eventoFound = await this.participanteEventoRepository.findOne({ - where: { - id_participante, - id_evento - } - }) - - if (!participante_eventoFound) { - return new HttpException('Participante not found', HttpStatus.NOT_FOUND) - } - - 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); - } + participante_eventoFound.fecha_asistencia = new Date(); + participante_eventoFound.asistio = true; + return this.participanteEventoRepository.save(participante_eventoFound); + } } diff --git a/src/pregunta/dto/create-pregunta.dto.ts b/src/pregunta/dto/create-pregunta.dto.ts index 6508cb9..5c14fe5 100644 --- a/src/pregunta/dto/create-pregunta.dto.ts +++ b/src/pregunta/dto/create-pregunta.dto.ts @@ -1,12 +1,9 @@ import { Type } from 'class-transformer'; import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, ValidateNested } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; +import { TiposValidacion } from '../entities/pregunta.entity'; +import { TipoPreguntaEnum } from 'src/tipo_pregunta/entities/tipo_pregunta.entity'; -export enum TipoPreguntaEnum { - CERRADA = 'Cerrada', - ABIERTA = 'Abierta', - MULTIPLE = 'Multiple' -} export class OpcionDto { @ApiProperty({ @@ -49,19 +46,18 @@ export class CreatePreguntaDto { description: 'Tipo de pregunta', enum: Object.values(TipoPreguntaEnum), enumName: 'TipoPreguntaEnum', - example: TipoPreguntaEnum.CERRADA, examples: { 'cerrada': { summary: 'Pregunta de opción única', - value: TipoPreguntaEnum.CERRADA + value: TipoPreguntaEnum.Cerrada }, 'abierta': { summary: 'Pregunta de texto libre', - value: TipoPreguntaEnum.ABIERTA + value: TipoPreguntaEnum.AbiertaParrafo }, 'multiple': { summary: 'Pregunta de selección múltiple', - value: TipoPreguntaEnum.MULTIPLE + value: TipoPreguntaEnum.Multiple } } }) @@ -69,7 +65,7 @@ export class CreatePreguntaDto { @IsEnum(TipoPreguntaEnum, { message: 'El tipo debe ser uno de los siguientes valores: Cerrada, Abierta, Multiple' }) - tipo: string; + tipo: TipoPreguntaEnum; @ApiProperty({ description: 'Contador de opciones (se calcula automáticamente)', @@ -79,15 +75,6 @@ export class CreatePreguntaDto { @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, @@ -96,7 +83,7 @@ export class CreatePreguntaDto { }) @IsOptional() @IsString() - validacion?: string; + validacion?: TiposValidacion; @ApiProperty({ description: 'Lista de opciones para preguntas de tipo Cerrada o Multiple', diff --git a/src/pregunta/entities/pregunta.entity.ts b/src/pregunta/entities/pregunta.entity.ts index b936a3f..15c46a0 100644 --- a/src/pregunta/entities/pregunta.entity.ts +++ b/src/pregunta/entities/pregunta.entity.ts @@ -1,8 +1,28 @@ +import { IsEnum, IsOptional } from 'class-validator'; import { PreguntaOpcion } from 'src/pregunta_opcion/entities/pregunta_opcion.entity'; import { SeccionPregunta } from 'src/seccion_pregunta/entities/seccion_pregunta.entity'; import { TipoPregunta } from 'src/tipo_pregunta/entities/tipo_pregunta.entity'; -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + OneToMany, + JoinColumn, +} from 'typeorm'; +export enum TiposValidacion { + CORREO = 'correo', + CORREO_INSTITUCIONAL = 'correo_institucional', + TELEFONO = 'telefono', + NOMBRE = 'nombre', + ENTERO = 'entero', + DECIMAL = 'decimal', + COMUNIDAD_ALUMNO = 'comunidad_alumno', + CUENTA_ALUMNO = 'cuenta_alumno', + COMUNIDAD_TRABAJADOR = 'comunidad_trabajador', + CUENTA_TRABAJADOR = 'cuenta_trabajador', +} @Entity() export class Pregunta { @@ -18,22 +38,24 @@ export class Pregunta { @Column({ default: false }) obligatoria: boolean; - @ManyToOne(() => TipoPregunta, tipo => tipo.preguntas) + @ManyToOne(() => TipoPregunta, (tipo) => tipo.preguntas) @JoinColumn({ name: 'id_tipo_pregunta' }) tipoPregunta: TipoPregunta; - + @Column() id_tipo_pregunta: number; - @Column({ type: 'int', nullable: true }) - id_opcion_dependiente?: number; + @Column({ type: 'enum', enum: TiposValidacion, nullable: true }) + @IsOptional() + @IsEnum(TiposValidacion, { message: 'Validación no permitida' }) + validacion?: TiposValidacion; - @Column({ nullable: true }) - validacion?: string; - - @OneToMany(() => SeccionPregunta, seccionPregunta => seccionPregunta.pregunta) + @OneToMany( + () => SeccionPregunta, + (seccionPregunta) => seccionPregunta.pregunta, + ) seccionPreguntas: SeccionPregunta[]; - @OneToMany(() => PreguntaOpcion, preguntaOpcion => preguntaOpcion.pregunta) + @OneToMany(() => PreguntaOpcion, (preguntaOpcion) => preguntaOpcion.pregunta) opciones: PreguntaOpcion[]; -} \ No newline at end of file +} diff --git a/src/pregunta/pregunta.controller.spec.ts b/src/pregunta/pregunta.controller.spec.ts deleted file mode 100644 index f2fc905..0000000 --- a/src/pregunta/pregunta.controller.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PreguntaController } from './pregunta.controller'; -import { PreguntaService } from './pregunta.service'; - -describe('PreguntaController', () => { - let controller: PreguntaController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [PreguntaController], - providers: [PreguntaService], - }).compile(); - - controller = module.get(PreguntaController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/pregunta/pregunta.documentation.ts b/src/pregunta/pregunta.documentation.ts index 0641201..d9103e0 100644 --- a/src/pregunta/pregunta.documentation.ts +++ b/src/pregunta/pregunta.documentation.ts @@ -1,6 +1,12 @@ -import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + ApiBody, + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; import { applyDecorators } from '@nestjs/common'; -import { TipoPreguntaEnum } from './dto/create-pregunta.dto'; +import { TipoPreguntaEnum } from 'src/tipo_pregunta/entities/tipo_pregunta.entity'; export class PreguntaApiDocumentation { // Decoradores para toda la clase del controlador @@ -10,7 +16,7 @@ export class PreguntaApiDocumentation { static ApiCreate = applyDecorators( ApiOperation({ summary: 'Crear una nueva pregunta', - description: 'Crea una nueva pregunta para una sección' + description: 'Crea una nueva pregunta para una sección', }), ApiBody({ description: 'Datos de la pregunta a crear', @@ -20,29 +26,30 @@ export class PreguntaApiDocumentation { properties: { titulo: { type: 'string', example: '¿Eres parte de la comunidad?' }, obligatoria: { type: 'boolean', example: true }, - tipo: { - type: 'string', + tipo: { + type: 'string', enum: Object.values(TipoPreguntaEnum), - description: 'Tipo de pregunta: Cerrada (opción única), Abierta (texto libre), Multiple (varias opciones)', - example: TipoPreguntaEnum.CERRADA + 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' + 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: { type: 'object', properties: { - valor: { type: 'string', example: 'Si' } - } - } - } - } + valor: { type: 'string', example: 'Si' }, + }, + }, + }, + }, }, examples: { cerrada: { @@ -50,41 +57,38 @@ export class PreguntaApiDocumentation { value: { titulo: '¿Eres parte de la comunidad de la FES Acatlán?', obligatoria: true, - tipo: TipoPreguntaEnum.CERRADA, + tipo: TipoPreguntaEnum.Cerrada, validacion: null, - opciones: [ - { valor: 'Sí' }, - { valor: 'No' } - ] - } + 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 - } + tipo: TipoPreguntaEnum.AbiertaParrafo, + 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, + tipo: TipoPreguntaEnum.Multiple, validacion: null, opciones: [ { valor: 'Rapidez' }, { valor: 'Atención al cliente' }, - { valor: 'Facilidad de uso' } - ] - } - } - } + { valor: 'Facilidad de uso' }, + ], + }, + }, + }, }), - ApiResponse({ - status: 201, + ApiResponse({ + status: 201, description: 'Pregunta creada correctamente', schema: { type: 'object', @@ -94,20 +98,19 @@ 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 }, - validacion: { type: 'string', example: 'correo' } - } - } + validacion: { type: 'string', example: 'correo' }, + }, + }, }), ApiResponse({ status: 400, description: 'Datos de la pregunta inválidos' }), - ApiResponse({ status: 500, description: 'Error interno del servidor' }) + ApiResponse({ status: 500, description: 'Error interno del servidor' }), ); // Documentación para obtener todas las preguntas static ApiGetAll = applyDecorators( ApiOperation({ summary: 'Obtener todas las preguntas', - description: 'Retorna una lista de todas las preguntas registradas' + description: 'Retorna una lista de todas las preguntas registradas', }), ApiResponse({ status: 200, @@ -118,31 +121,33 @@ export class PreguntaApiDocumentation { type: 'object', properties: { id_pregunta: { type: 'number', example: 1 }, - pregunta: { type: 'string', example: '¿Eres parte de la comunidad?' }, + pregunta: { + type: 'string', + example: '¿Eres parte de la comunidad?', + }, 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 }, - validacion: { type: 'string', example: 'correo' } - } - } - } + validacion: { type: 'string', example: 'correo' }, + }, + }, + }, }), - ApiResponse({ status: 500, description: 'Error interno del servidor' }) + ApiResponse({ status: 500, description: 'Error interno del servidor' }), ); // Documentación para obtener una pregunta por ID static ApiGetOne = applyDecorators( ApiOperation({ summary: 'Obtener una pregunta por ID', - description: 'Retorna una pregunta específica por su ID' + description: 'Retorna una pregunta específica por su ID', }), ApiParam({ name: 'id', description: 'ID de la pregunta', required: true, type: 'number', - example: 1 + example: 1, }), ApiResponse({ status: 200, @@ -155,7 +160,6 @@ 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 }, validacion: { type: 'string', example: 'correo' }, opciones: { type: 'array', @@ -163,29 +167,29 @@ export class PreguntaApiDocumentation { type: 'object', properties: { id_opcion: { type: 'number', example: 1 }, - opcion: { type: 'string', example: 'Si' } - } - } - } - } - } + opcion: { type: 'string', example: 'Si' }, + }, + }, + }, + }, + }, }), ApiResponse({ status: 404, description: 'Pregunta no encontrada' }), - ApiResponse({ status: 500, description: 'Error interno del servidor' }) + ApiResponse({ status: 500, description: 'Error interno del servidor' }), ); // Documentación para actualizar una pregunta static ApiUpdate = applyDecorators( ApiOperation({ summary: 'Actualizar una pregunta', - description: 'Actualiza los datos de una pregunta existente' + description: 'Actualiza los datos de una pregunta existente', }), ApiParam({ name: 'id', description: 'ID de la pregunta a actualizar', required: true, type: 'number', - example: 1 + example: 1, }), ApiBody({ description: 'Datos a actualizar de la pregunta', @@ -193,9 +197,9 @@ export class PreguntaApiDocumentation { type: 'object', properties: { pregunta: { type: 'string', example: 'Pregunta actualizada' }, - obligatoria: { type: 'boolean', example: false } - } - } + obligatoria: { type: 'boolean', example: false }, + }, + }, }), ApiResponse({ status: 200, @@ -203,27 +207,30 @@ export class PreguntaApiDocumentation { schema: { type: 'object', properties: { - affected: { type: 'number', example: 1 } - } - } + affected: { type: 'number', example: 1 }, + }, + }, + }), + ApiResponse({ + status: 400, + description: 'Datos de actualización inválidos', }), - ApiResponse({ status: 400, description: 'Datos de actualización inválidos' }), ApiResponse({ status: 404, description: 'Pregunta no encontrada' }), - ApiResponse({ status: 500, description: 'Error interno del servidor' }) + ApiResponse({ status: 500, description: 'Error interno del servidor' }), ); // Documentación para eliminar una pregunta static ApiRemove = applyDecorators( ApiOperation({ summary: 'Eliminar una pregunta', - description: 'Elimina permanentemente una pregunta por su ID' + description: 'Elimina permanentemente una pregunta por su ID', }), ApiParam({ name: 'id', description: 'ID de la pregunta a eliminar', required: true, type: 'number', - example: 1 + example: 1, }), ApiResponse({ status: 200, @@ -231,11 +238,11 @@ export class PreguntaApiDocumentation { schema: { type: 'object', properties: { - affected: { type: 'number', example: 1 } - } - } + affected: { type: 'number', example: 1 }, + }, + }, }), ApiResponse({ status: 404, description: 'Pregunta no encontrada' }), - ApiResponse({ status: 500, description: 'Error interno del servidor' }) + ApiResponse({ status: 500, description: 'Error interno del servidor' }), ); -} \ No newline at end of file +} diff --git a/src/pregunta/pregunta.service.spec.ts b/src/pregunta/pregunta.service.spec.ts deleted file mode 100644 index 1a4b35c..0000000 --- a/src/pregunta/pregunta.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PreguntaService } from './pregunta.service'; - -describe('PreguntaService', () => { - let service: PreguntaService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [PreguntaService], - }).compile(); - - service = module.get(PreguntaService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/src/pregunta/pregunta.service.ts b/src/pregunta/pregunta.service.ts index c339468..627a3df 100644 --- a/src/pregunta/pregunta.service.ts +++ b/src/pregunta/pregunta.service.ts @@ -42,7 +42,6 @@ export class PreguntaService { pregunta.pregunta = createPreguntaDto.titulo; pregunta.obligatoria = createPreguntaDto.obligatoria || false; 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; @@ -158,7 +157,6 @@ export class PreguntaService { // Actualizar los campos básicos de la pregunta 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 diff --git a/src/respuesta_participante_abierta/entities/respuesta_participante_abierta.entity.ts b/src/respuesta_participante_abierta/entities/respuesta_participante_abierta.entity.ts index 4a6262e..80c2ee8 100644 --- a/src/respuesta_participante_abierta/entities/respuesta_participante_abierta.entity.ts +++ b/src/respuesta_participante_abierta/entities/respuesta_participante_abierta.entity.ts @@ -1,7 +1,12 @@ import { CuestionarioRespondido } from 'src/cuestionario_respondido/entities/cuestionario_respondido.entity'; import { Pregunta } from 'src/pregunta/entities/pregunta.entity'; -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; - +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, +} from 'typeorm'; @Entity('respuesta_participante_abierta') export class RespuestaParticipanteAbierta { @@ -13,8 +18,8 @@ export class RespuestaParticipanteAbierta { // Relación con CuestionarioRespondido @ManyToOne( - () => CuestionarioRespondido, - cuestionarioRespondido => cuestionarioRespondido.respuestasAbiertas + () => CuestionarioRespondido, + (cuestionarioRespondido) => cuestionarioRespondido.respuestasAbiertas, ) @JoinColumn({ name: 'id_cuestionario_respondido' }) cuestionarioRespondido: CuestionarioRespondido; @@ -22,6 +27,4 @@ export class RespuestaParticipanteAbierta { @ManyToOne(() => Pregunta) @JoinColumn({ name: 'id_pregunta' }) pregunta: Pregunta; - - -} \ No newline at end of file +} diff --git a/src/respuesta_participante_cerrada/entities/respuesta_participante_cerrada.entity.ts b/src/respuesta_participante_cerrada/entities/respuesta_participante_cerrada.entity.ts index 0d65cb0..6a9f9ac 100644 --- a/src/respuesta_participante_cerrada/entities/respuesta_participante_cerrada.entity.ts +++ b/src/respuesta_participante_cerrada/entities/respuesta_participante_cerrada.entity.ts @@ -1,20 +1,29 @@ import { CuestionarioRespondido } from 'src/cuestionario_respondido/entities/cuestionario_respondido.entity'; import { PreguntaOpcion } from 'src/pregunta_opcion/entities/pregunta_opcion.entity'; -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm'; - +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, +} from 'typeorm'; @Entity('respuesta_participante_cerrada') export class RespuestaParticipanteCerrada { @PrimaryGeneratedColumn() idRespuestaParticipanteCerrada: number; - @ManyToOne(() => CuestionarioRespondido, cuestionarioRespondido => cuestionarioRespondido.respuestasCerradas) + @ManyToOne( + () => CuestionarioRespondido, + (cuestionarioRespondido) => cuestionarioRespondido.respuestasCerradas, + ) @JoinColumn({ name: 'id_cuestionario_respondido' }) cuestionarioRespondido: CuestionarioRespondido; - @ManyToOne(() => PreguntaOpcion, preguntaOpcion => preguntaOpcion.respuestaParticipanteCerrada) + @ManyToOne( + () => PreguntaOpcion, + (preguntaOpcion) => preguntaOpcion.respuestaParticipanteCerrada, + ) @JoinColumn({ name: 'id_pregunta_opcion' }) preguntaOpcion: PreguntaOpcion; - - -} \ No newline at end of file +} diff --git a/src/seccion/seccion.service.ts b/src/seccion/seccion.service.ts index 7915dd1..c890a07 100644 --- a/src/seccion/seccion.service.ts +++ b/src/seccion/seccion.service.ts @@ -25,7 +25,7 @@ export class SeccionService { private opcionRepository: Repository, @InjectRepository(PreguntaOpcion) private preguntaOpcionRepository: Repository, - private dataSource: DataSource + private dataSource: DataSource, ) {} async create(createSeccionDto: CreateSeccionDto) { @@ -39,58 +39,59 @@ export class SeccionService { seccion.titulo = createSeccionDto.titulo; seccion.descripcion = createSeccionDto.descripcion; seccion.contador_pregunta = createSeccionDto.preguntas?.length || 0; - + const savedSeccion = await queryRunner.manager.save(seccion); - + // 2. Crear preguntas y vincularlas a la sección if (createSeccionDto.preguntas && createSeccionDto.preguntas.length > 0) { for (let i = 0; i < createSeccionDto.preguntas.length; i++) { const preguntaDto = createSeccionDto.preguntas[i]; - + // Obtener el tipo de pregunta por su nombre const tipoPregunta = await queryRunner.manager.findOne(TipoPregunta, { where: { tipo_pregunta: preguntaDto.tipo }, }); - + if (!tipoPregunta) { - throw new Error(`Tipo de pregunta '${preguntaDto.tipo}' no encontrado`); + throw new Error( + `Tipo de pregunta '${preguntaDto.tipo}' no encontrado`, + ); } - + // Crear pregunta const pregunta = new Pregunta(); pregunta.pregunta = preguntaDto.titulo; pregunta.obligatoria = preguntaDto.obligatoria || false; pregunta.id_tipo_pregunta = tipoPregunta.id_tipo; - pregunta.id_opcion_dependiente = preguntaDto.id_opcion_dependiente || undefined; pregunta.contador_opcion = preguntaDto.opciones?.length || 0; - + const savedPregunta = await queryRunner.manager.save(pregunta); - + // Vincular pregunta con sección const seccionPregunta = new SeccionPregunta(); seccionPregunta.id_seccion = savedSeccion.id_seccion; seccionPregunta.id_pregunta = savedPregunta.id_pregunta; seccionPregunta.posicion = i + 1; - + await queryRunner.manager.save(seccionPregunta); - + // Crear opciones para preguntas de tipo multiple/radio if (preguntaDto.opciones && preguntaDto.opciones.length > 0) { for (let j = 0; j < preguntaDto.opciones.length; j++) { const opcionDto = preguntaDto.opciones[j]; - + // Crear opción const opcion = new Opcion(); opcion.opcion = opcionDto.valor; - + const savedOpcion = await queryRunner.manager.save(opcion); - + // Vincular opción con pregunta const preguntaOpcion = new PreguntaOpcion(); preguntaOpcion.id_pregunta = savedPregunta.id_pregunta; preguntaOpcion.opcion = savedOpcion; preguntaOpcion.posicion = j + 1; - + await queryRunner.manager.save(preguntaOpcion); } } @@ -98,7 +99,7 @@ export class SeccionService { } await queryRunner.commitTransaction(); - + return { success: true, seccion: savedSeccion, @@ -116,90 +117,91 @@ export class SeccionService { } async findOne(id: number) { - const seccion = await this.seccionRepository.findOne({ - where: { id_seccion: id } + const seccion = await this.seccionRepository.findOne({ + where: { id_seccion: id }, }); - + if (!seccion) { throw new NotFoundException(`Sección con ID ${id} no encontrada`); } - + return seccion; } async findWithPreguntas(id: number) { const seccion = await this.seccionRepository.findOne({ - where: { id_seccion: id } + where: { id_seccion: id }, }); - + if (!seccion) { throw new NotFoundException(`Sección con ID ${id} no encontrada`); } - + // Obtener las relaciones seccion-pregunta para esta sección const seccionPreguntas = await this.seccionPreguntaRepository.find({ where: { id_seccion: id }, - relations: ['pregunta'] + relations: ['pregunta'], }); - + // Extraer y organizar las preguntas const preguntas = await Promise.all( seccionPreguntas.map(async (sp) => { const pregunta = await this.preguntaRepository.findOne({ - where: { id_pregunta: sp.id_pregunta } + where: { id_pregunta: sp.id_pregunta }, }); - + // Obtener las opciones para preguntas de tipo multiple/radio const preguntaOpciones = await this.preguntaOpcionRepository.find({ where: { id_pregunta: sp.id_pregunta }, relations: ['opcion'], - order: { posicion: 'ASC' } + order: { posicion: 'ASC' }, }); - - const opciones = preguntaOpciones.map(po => po.opcion); - + + const opciones = preguntaOpciones.map((po) => po.opcion); + return { ...pregunta, posicion: sp.posicion, - opciones + opciones, }; - }) + }), ); - + // Ordenar las preguntas por posición preguntas.sort((a, b) => a.posicion - b.posicion); - + return { ...seccion, - preguntas + preguntas, }; } async update(id: number, updateSeccionDto: UpdateSeccionDto) { const seccion = await this.seccionRepository.findOne({ - where: { id_seccion: id } + where: { id_seccion: id }, }); - + if (!seccion) { throw new NotFoundException(`Sección con ID ${id} no encontrada`); } - + // Actualizar solo los campos proporcionados if (updateSeccionDto.titulo) seccion.titulo = updateSeccionDto.titulo; - if (updateSeccionDto.descripcion !== undefined) seccion.descripcion = updateSeccionDto.descripcion; - + if (updateSeccionDto.descripcion !== undefined) + seccion.descripcion = updateSeccionDto.descripcion; + return this.seccionRepository.save(seccion); } async remove(id: number) { const seccion = await this.seccionRepository.findOne({ - where: { id_seccion: id } + where: { id_seccion: id }, }); - + if (!seccion) { throw new NotFoundException(`Sección con ID ${id} no encontrada`); } - + return this.seccionRepository.remove(seccion); } } diff --git a/src/tipo_cuestionario/entities/tipo_cuestionario.entity.ts b/src/tipo_cuestionario/entities/tipo_cuestionario.entity.ts index 3a2ccf5..b00540a 100644 --- a/src/tipo_cuestionario/entities/tipo_cuestionario.entity.ts +++ b/src/tipo_cuestionario/entities/tipo_cuestionario.entity.ts @@ -1,14 +1,27 @@ import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity'; -import { Entity, PrimaryGeneratedColumn, Column, ManyToMany, ManyToOne } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + OneToMany, +} from 'typeorm'; + +export enum TipoCuestionarioEnum { + REGISTRO = 'Registro', + EVALUACION = 'Evaluación', +} @Entity('tipo_cuestionario') export class TipoCuestionario { @PrimaryGeneratedColumn() id_tipo_cuestionario: number; - @Column({ type: 'varchar', length: 30 }) - tipo_cuestionario: string; + @Column({ type: 'enum', enum: TipoCuestionarioEnum }) + tipo_cuestionario: TipoCuestionarioEnum; - @ManyToOne(()=> Cuestionario,(cuestionario)=> cuestionario.id_tipo_cuestionario) - cuestionario:Cuestionario[]; + @OneToMany( + () => Cuestionario, + (cuestionario) => cuestionario.tipoCuestionario, + ) + cuestionarios: Cuestionario[]; } diff --git a/src/tipo_pregunta/entities/tipo_pregunta.entity.ts b/src/tipo_pregunta/entities/tipo_pregunta.entity.ts index e6bea46..805c5d8 100644 --- a/src/tipo_pregunta/entities/tipo_pregunta.entity.ts +++ b/src/tipo_pregunta/entities/tipo_pregunta.entity.ts @@ -1,14 +1,21 @@ import { Pregunta } from 'src/pregunta/entities/pregunta.entity'; import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'; +export enum TipoPreguntaEnum { + AbiertaParrafo = 'Abierta (Parrafo)', + AbiertaRespuestaCorta = 'Abierta (Respuesta corta)', + Cerrada = 'Cerrada', + Multiple = 'Multiple', +} + @Entity() export class TipoPregunta { @PrimaryGeneratedColumn() id_tipo: number; - @Column() - tipo_pregunta: string; + @Column({ type: 'enum', enum: TipoPreguntaEnum }) + tipo_pregunta: TipoPreguntaEnum; - @OneToMany(() => Pregunta, pregunta => pregunta.tipoPregunta) + @OneToMany(() => Pregunta, (pregunta) => pregunta.tipoPregunta) preguntas: Pregunta[]; -} \ No newline at end of file +} diff --git a/src/tipo_pregunta/tipo_pregunta.service.ts b/src/tipo_pregunta/tipo_pregunta.service.ts index 37f97c0..5518852 100644 --- a/src/tipo_pregunta/tipo_pregunta.service.ts +++ b/src/tipo_pregunta/tipo_pregunta.service.ts @@ -3,7 +3,7 @@ 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'; +import { TipoPregunta, TipoPreguntaEnum } from './entities/tipo_pregunta.entity'; @Injectable() export class TipoPreguntaService implements OnModuleInit { @@ -17,20 +17,15 @@ export class TipoPreguntaService implements OnModuleInit { } private async seedTipoPreguntas() { - const defaultTipos = [ - { tipo_pregunta: 'Cerrada' }, - { tipo_pregunta: 'Abierta' }, - { tipo_pregunta: 'Multiple' }, - { tipo_pregunta: 'Texto' } - ]; + const enumValues = Object.values(TipoPreguntaEnum); - for (const tipo of defaultTipos) { + for (const tipo_pregunta of enumValues) { const existingTipo = await this.tipoPreguntaRepository.findOne({ - where: { tipo_pregunta: tipo.tipo_pregunta }, + where: { tipo_pregunta: tipo_pregunta }, }); if (!existingTipo) { - await this.tipoPreguntaRepository.save(tipo); + await this.tipoPreguntaRepository.save({ tipo_pregunta }); } } } @@ -45,7 +40,7 @@ export class TipoPreguntaService implements OnModuleInit { findOne(id: number) { return this.tipoPreguntaRepository.findOne({ - where: { id_tipo: id } + where: { id_tipo: id }, }); } diff --git a/src/tipo_user/entities/tipo_user.entity.ts b/src/tipo_user/entities/tipo_user.entity.ts new file mode 100644 index 0000000..53a3ed0 --- /dev/null +++ b/src/tipo_user/entities/tipo_user.entity.ts @@ -0,0 +1,15 @@ +import { Administrador } from 'src/administrador/entities/administrador.entity'; +import { Participante } from 'src/participante/entities/participante.entity'; +import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity() +export class TipoUser { + @PrimaryGeneratedColumn() + id_tipo_user: number; + + @Column() + tipo: string; + + @OneToMany(() => Administrador, (administrador) => administrador.tipoUser) + administrador: Administrador[]; +} diff --git a/src/tipo_user/tipo_user.controller.ts b/src/tipo_user/tipo_user.controller.ts index abed59a..a997c6a 100644 --- a/src/tipo_user/tipo_user.controller.ts +++ b/src/tipo_user/tipo_user.controller.ts @@ -1,6 +1,6 @@ import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post } from '@nestjs/common'; import { TipoUserService } from './tipo_user.service'; -import { TipoUser } from './tipo_user.entity'; +import { TipoUser } from './entities/tipo_user.entity'; import { CreateTipoUserDto } from './dto/create-tipo-user.dto'; import { UpdateTipoUserDto } from './dto/update.tipo_user.dto'; import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBody } from '@nestjs/swagger'; diff --git a/src/tipo_user/tipo_user.entity.ts b/src/tipo_user/tipo_user.entity.ts deleted file mode 100644 index a20976e..0000000 --- a/src/tipo_user/tipo_user.entity.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Administrador } from "src/administrador/administrador.entity"; -import { Participante } from "src/participante/participante.entity"; -import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm"; - -@Entity() -export class TipoUser { - @PrimaryGeneratedColumn() - id_tipo_user: number; - - @Column() - tipo: string; - - @Column() - nombre: string; - - @Column({nullable: true}) - apellido_p: string; - - @Column({nullable: true}) - apellido_m: string; - - @OneToMany(() => Participante, participante => participante.tipo_user) - participante: Participante[] - - @OneToMany(() => Administrador, administrador => administrador.tipoUser) - administrador: Administrador[] - -} \ No newline at end of file diff --git a/src/tipo_user/tipo_user.module.ts b/src/tipo_user/tipo_user.module.ts index 2caf76b..a1fa91e 100644 --- a/src/tipo_user/tipo_user.module.ts +++ b/src/tipo_user/tipo_user.module.ts @@ -1,10 +1,10 @@ import { Module } from '@nestjs/common'; import { TipoUserService } from './tipo_user.service'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { TipoUser } from './tipo_user.entity'; +import { TipoUser } from './entities/tipo_user.entity'; import { TipoUserController } from './tipo_user.controller'; -import { Administrador } from 'src/administrador/administrador.entity'; -import { Participante } from 'src/participante/participante.entity'; +import { Administrador } from 'src/administrador/entities/administrador.entity'; +import { Participante } from 'src/participante/entities/participante.entity'; @Module({ imports: [TypeOrmModule.forFeature([TipoUser, Administrador, Participante])], diff --git a/src/tipo_user/tipo_user.service.ts b/src/tipo_user/tipo_user.service.ts index f86d4a4..8b372e8 100644 --- a/src/tipo_user/tipo_user.service.ts +++ b/src/tipo_user/tipo_user.service.ts @@ -1,22 +1,29 @@ -import { BadRequestException, HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { + BadRequestException, + HttpException, + HttpStatus, + Injectable, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { TipoUser } from './tipo_user.entity'; +import { TipoUser } from './entities/tipo_user.entity'; import { Repository } from 'typeorm'; import { CreateTipoUserDto } from './dto/create-tipo-user.dto'; import { UpdateTipoUserDto } from './dto/update.tipo_user.dto'; -import { Participante } from 'src/participante/participante.entity'; -import { Administrador } from 'src/administrador/administrador.entity'; +import { Participante } from 'src/participante/entities/participante.entity'; +import { Administrador } from 'src/administrador/entities/administrador.entity'; @Injectable() export class TipoUserService { + constructor( + @InjectRepository(TipoUser) + private tipoUserRepository: Repository, + @InjectRepository(Participante) + private participanteRepository: Repository, + @InjectRepository(Administrador) + private administradorRepository: Repository, + ) {} - constructor( - @InjectRepository(TipoUser) private tipoUserRepository: Repository, - @InjectRepository(Participante) private participanteRepository: Repository, - @InjectRepository(Administrador) private administradorRepository: Repository, - ) {} - - /* + /* //Funcion para registrar usuario async registrarUsuario(dto: CreateTipoUserDto) { // Crear el registro en tipo_user @@ -56,71 +63,69 @@ export class TipoUserService { } } */ - - async createTipoUser(tipoUser: CreateTipoUserDto) { - // falta hacer la validacion - - const tipo_userFound = await this.tipoUserRepository.findOne({ - where: { - nombre: tipoUser.nombre, - tipo: tipoUser.tipo, - //id_tipo_user: - } - }) - - if(tipo_userFound) { - return new HttpException('Tipo User already exict', HttpStatus.NOT_FOUND); - } - const tipo_user = this.tipoUserRepository.create(tipoUser) - - return this.tipoUserRepository.save(tipo_user) + async createTipoUser(tipoUser: CreateTipoUserDto) { + // falta hacer la validacion + + const tipo_userFound = await this.tipoUserRepository.findOne({ + where: { + tipo: tipoUser.tipo, + //id_tipo_user: + }, + }); + + if (tipo_userFound) { + return new HttpException('Tipo User already exict', HttpStatus.NOT_FOUND); } - getTipoUsers() { - return this.tipoUserRepository.find({ - relations: ['participante', 'administrador'] - }) + const tipo_user = this.tipoUserRepository.create(tipoUser); + + return this.tipoUserRepository.save(tipo_user); + } + + getTipoUsers() { + return this.tipoUserRepository.find({ + relations: ['participante', 'administrador'], + }); + } + + async getTipoUser(id: number) { + const tipo_userFound = await this.tipoUserRepository.findOne({ + where: { + id_tipo_user: id, + }, + relations: ['participante', 'administrador'], + }); + + if (!tipo_userFound) { + return new HttpException('User not found', HttpStatus.NOT_FOUND); } - async getTipoUser(id: number) { - const tipo_userFound = await this.tipoUserRepository.findOne({ - where: { - id_tipo_user: id - }, - relations: ['participante', 'administrador'] - }); - - if(!tipo_userFound) { - return new HttpException('User not found', HttpStatus.NOT_FOUND); - } - - return tipo_userFound; + return tipo_userFound; + } + + async deleteTipoUser(id_tipo_user: number) { + const result = await this.tipoUserRepository.delete({ id_tipo_user }); + + if (result.affected === 0) { + return new HttpException('User not found', HttpStatus.NOT_FOUND); } - async deleteTipoUser(id_tipo_user: number) { - const result = await this.tipoUserRepository.delete({ id_tipo_user }) + return result; + } - if(result.affected === 0) { - return new HttpException('User not found', HttpStatus.NOT_FOUND); - } + async updateTipoUser(id_tipo_user: number, tipo_user: UpdateTipoUserDto) { + const tipo_userFound = await this.tipoUserRepository.findOne({ + where: { + id_tipo_user, + }, + }); - return result - } - - async updateTipoUser(id_tipo_user: number, tipo_user: UpdateTipoUserDto) { - const tipo_userFound = await this.tipoUserRepository.findOne({ - where: { - id_tipo_user - } - }) - - if(!tipo_userFound) { - return new HttpException('User not found', HttpStatus.NOT_FOUND) - } - - const updateTipoUser = Object.assign(tipo_userFound, tipo_user) - return this.tipoUserRepository.save(updateTipoUser) + if (!tipo_userFound) { + return new HttpException('User not found', HttpStatus.NOT_FOUND); } + const updateTipoUser = Object.assign(tipo_userFound, tipo_user); + return this.tipoUserRepository.save(updateTipoUser); + } } diff --git a/utils/get_formulario_feria.ts b/utils/get_formulario_feria.ts index dfffb3c..e84abf2 100644 --- a/utils/get_formulario_feria.ts +++ b/utils/get_formulario_feria.ts @@ -35,7 +35,6 @@ export const cuestionario_feria = { contador_opcion: 4, obligatoria: true, id_tipo_pregunta: 1, - id_opcion_dependiente: null, tipo_pregunta: { id_tipo: 1, tipo_pregunta: 'Cerrada', @@ -89,7 +88,6 @@ export const cuestionario_feria = { contador_opcion: 2, obligatoria: true, id_tipo_pregunta: 1, - id_opcion_dependiente: null, tipo_pregunta: { id_tipo: 1, tipo_pregunta: 'Cerrada', @@ -137,7 +135,6 @@ export const cuestionario_feria = { contador_opcion: 0, obligatoria: false, id_tipo_pregunta: 2, - id_opcion_dependiente: null, tipo_pregunta: { id_tipo: 2, tipo_pregunta: 'Abierta', @@ -154,7 +151,6 @@ export const cuestionario_feria = { contador_opcion: 3, obligatoria: false, id_tipo_pregunta: 3, // Puedes usar este ID para distinguir múltiples - id_opcion_dependiente: null, tipo_pregunta: { id_tipo: 3, tipo_pregunta: 'Multiple', -- 2.43.0 From 63e1d3183eab095ad6effdc2d1313a8ea13a751a Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 16 Jun 2025 12:23:49 -0600 Subject: [PATCH 02/14] Add TypeORM integration and seed method for TipoCuestionario --- .../tipo_cuestionario.module.ts | 3 ++ .../tipo_cuestionario.service.ts | 31 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/tipo_cuestionario/tipo_cuestionario.module.ts b/src/tipo_cuestionario/tipo_cuestionario.module.ts index 00cf32e..a89696d 100644 --- a/src/tipo_cuestionario/tipo_cuestionario.module.ts +++ b/src/tipo_cuestionario/tipo_cuestionario.module.ts @@ -1,8 +1,11 @@ import { Module } from '@nestjs/common'; import { TipoCuestionarioService } from './tipo_cuestionario.service'; import { TipoCuestionarioController } from './tipo_cuestionario.controller'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TipoCuestionario } from './entities/tipo_cuestionario.entity'; @Module({ + imports: [TypeOrmModule.forFeature([TipoCuestionario])], controllers: [TipoCuestionarioController], providers: [TipoCuestionarioService], }) diff --git a/src/tipo_cuestionario/tipo_cuestionario.service.ts b/src/tipo_cuestionario/tipo_cuestionario.service.ts index ba3da92..c15a3a5 100644 --- a/src/tipo_cuestionario/tipo_cuestionario.service.ts +++ b/src/tipo_cuestionario/tipo_cuestionario.service.ts @@ -1,9 +1,40 @@ import { Injectable } from '@nestjs/common'; import { CreateTipoCuestionarioDto } from './dto/create-tipo_cuestionario.dto'; import { UpdateTipoCuestionarioDto } from './dto/update-tipo_cuestionario.dto'; +import { InjectRepository } from '@nestjs/typeorm'; +import { + TipoCuestionario, + TipoCuestionarioEnum, +} from './entities/tipo_cuestionario.entity'; +import { Repository } from 'typeorm'; @Injectable() export class TipoCuestionarioService { + constructor( + @InjectRepository(TipoCuestionario) + private tipoCuestionarioRepository: Repository, + ) {} + + async onModuleInit() { + await this.seedTipoCuestionarios(); + } + + private async seedTipoCuestionarios() { + const enumValues = Object.values(TipoCuestionarioEnum); + + for (const tipoCuestionario of enumValues) { + const existingTipo = await this.tipoCuestionarioRepository.findOne({ + where: { tipo_cuestionario: tipoCuestionario }, + }); + + if (!existingTipo) { + await this.tipoCuestionarioRepository.save({ + tipo_cuestionario: tipoCuestionario, + }); + } + } + } + create(createTipoCuestionarioDto: CreateTipoCuestionarioDto) { return 'This action adds a new tipoCuestionario'; } -- 2.43.0 From 5f302df2a7adf6fcd88c81aded92acbd3215c3ed Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 16 Jun 2025 13:49:12 -0600 Subject: [PATCH 03/14] Add @nestjs/serve-static dependency and update file paths for banner uploads; remove unused form utility files --- package-lock.json | 28 +++ package.json | 1 + src/app.module.ts | 1 - src/cuestionario/cuestionario.controller.ts | 2 +- .../docs/cuestionario.documentation.ts | 6 +- src/db/typeorm.config.ts | 2 +- src/evento/evento.controller.ts | 2 +- src/main.ts | 9 +- .../banner-1750103174963-705371719.jpeg | Bin 0 -> 76323 bytes .../banner-1750103308438-995542211.jpeg | Bin 0 -> 76323 bytes utils/crear_formulario_feria.ts | 68 ------ utils/get_formulario_feria.ts | 193 ------------------ utils/load-form.js | 46 ----- utils/mandar_formulario.json | 0 utils/respuesta_1.json | 0 utils/respuesta_2.json | 0 utils/respuesta_formulario_feria.ts | 21 -- utils/test-crear-formulario.ts | 28 --- 18 files changed, 43 insertions(+), 364 deletions(-) create mode 100644 uploads/banners/banner-1750103174963-705371719.jpeg create mode 100644 uploads/banners/banner-1750103308438-995542211.jpeg delete mode 100644 utils/crear_formulario_feria.ts delete mode 100644 utils/get_formulario_feria.ts delete mode 100644 utils/load-form.js delete mode 100644 utils/mandar_formulario.json delete mode 100644 utils/respuesta_1.json delete mode 100644 utils/respuesta_2.json delete mode 100644 utils/respuesta_formulario_feria.ts delete mode 100644 utils/test-crear-formulario.ts diff --git a/package-lock.json b/package-lock.json index 4e4a26b..cd34f6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@nestjs/mapped-types": "*", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/serve-static": "^5.0.3", "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.0", "@types/bcrypt": "^5.0.2", @@ -2540,6 +2541,33 @@ "tslib": "^2.1.0" } }, + "node_modules/@nestjs/serve-static": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@nestjs/serve-static/-/serve-static-5.0.3.tgz", + "integrity": "sha512-0jFjTlSVSLrI+mot8lfm+h2laXtKzCvgsVStv9T1ZBZTDwS26gM5czIhIESmWAod0PfrbCDFiu9C1MglObL8VA==", + "license": "MIT", + "dependencies": { + "path-to-regexp": "8.2.0" + }, + "peerDependencies": { + "@fastify/static": "^8.0.4", + "@nestjs/common": "^11.0.2", + "@nestjs/core": "^11.0.2", + "express": "^5.0.1", + "fastify": "^5.2.1" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "express": { + "optional": true + }, + "fastify": { + "optional": true + } + } + }, "node_modules/@nestjs/swagger": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.0.tgz", diff --git a/package.json b/package.json index 9d5c151..89f9c62 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@nestjs/mapped-types": "*", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/serve-static": "^5.0.3", "@nestjs/swagger": "^11.2.0", "@nestjs/typeorm": "^11.0.0", "@types/bcrypt": "^5.0.2", diff --git a/src/app.module.ts b/src/app.module.ts index 7496496..070febc 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -31,7 +31,6 @@ import { AlumnosModule } from './alumnos/alumnos.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), - TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: createMainDbConfig, diff --git a/src/cuestionario/cuestionario.controller.ts b/src/cuestionario/cuestionario.controller.ts index a88662a..c7e5314 100644 --- a/src/cuestionario/cuestionario.controller.ts +++ b/src/cuestionario/cuestionario.controller.ts @@ -46,7 +46,7 @@ export class CuestionarioController { @UseInterceptors( FileInterceptor('banner', { storage: diskStorage({ - destination: './public/banners', // Asegúrate de que esta carpeta exista + destination: './uploads/banners', // Asegúrate de que esta carpeta exista filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); diff --git a/src/cuestionario/docs/cuestionario.documentation.ts b/src/cuestionario/docs/cuestionario.documentation.ts index cf8884f..b4a8550 100644 --- a/src/cuestionario/docs/cuestionario.documentation.ts +++ b/src/cuestionario/docs/cuestionario.documentation.ts @@ -6,7 +6,7 @@ import { ApiTags, getSchemaPath, } from '@nestjs/swagger'; -import { feriaSexualidad } from '../../../utils/crear_formulario_feria'; +/* import { feriaSexualidad } from '../../../utils/crear_formulario_feria'; */ import { applyDecorators } from '@nestjs/common'; import { FormularioDto } from '../dto/formulario.schema'; import { @@ -47,7 +47,7 @@ export class CuestionarioApiDocumentation { type: 'number', example: 1, }), - ApiResponse({ +/* ApiResponse({ status: 200, 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.)', @@ -58,7 +58,7 @@ export class CuestionarioApiDocumentation { example: feriaSexualidad, }, }, - }), + }), */ ApiResponse({ status: 404, description: 'Cuestionario no encontrado' }), ); diff --git a/src/db/typeorm.config.ts b/src/db/typeorm.config.ts index 7bef1a2..e893aa4 100644 --- a/src/db/typeorm.config.ts +++ b/src/db/typeorm.config.ts @@ -16,7 +16,7 @@ export const createMainDbConfig = async ( synchronize: true, retryAttempts: 5, retryDelay: 3000, - dropSchema: true, + dropSchema: false, }); export const createAlumnosDbConfig = async ( diff --git a/src/evento/evento.controller.ts b/src/evento/evento.controller.ts index 3592062..60cdb66 100644 --- a/src/evento/evento.controller.ts +++ b/src/evento/evento.controller.ts @@ -49,7 +49,7 @@ export class EventoController { @UseInterceptors( FileInterceptor('banner', { storage: diskStorage({ - destination: './public/banners', // Asegúrate de que esta carpeta exista + destination: './uploads/banners', // Asegúrate de que esta carpeta exista filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); diff --git a/src/main.ts b/src/main.ts index 1e616e4..528f3a9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,9 +3,11 @@ import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { join } from 'path'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); const configService = app.get(ConfigService); // Configuración de validación global @@ -17,6 +19,11 @@ async function bootstrap() { }), ); + app.useStaticAssets(join(__dirname, '..', 'uploads'), { + index: false, + prefix: '/', + }); + // CORS configuration app.enableCors({ origin: '*', diff --git a/uploads/banners/banner-1750103174963-705371719.jpeg b/uploads/banners/banner-1750103174963-705371719.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..b99380a7c3c8eb37bf8fcb493954ddcf8ff355e8 GIT binary patch literal 76323 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

rYp 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 bbzwsjntIwT{U`URL!O*s`{!HmFO&ZVdw%md literal 0 HcmV?d00001 diff --git a/uploads/banners/banner-1750103308438-995542211.jpeg b/uploads/banners/banner-1750103308438-995542211.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..b99380a7c3c8eb37bf8fcb493954ddcf8ff355e8 GIT binary patch literal 76323 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

rYp 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 bbzwsjntIwT{U`URL!O*s`{!HmFO&ZVdw%md literal 0 HcmV?d00001 diff --git a/utils/crear_formulario_feria.ts b/utils/crear_formulario_feria.ts deleted file mode 100644 index 0b2d77d..0000000 --- a/utils/crear_formulario_feria.ts +++ /dev/null @@ -1,68 +0,0 @@ -export const feriaSexualidad = { - nombre_form: 'Registro para la Feria de la Sexualidad - FES Acatlán', - 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.', - 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', - descripcion: - 'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.', - preguntas: [ - { - titulo: '¿Eres parte de la comunidad de la FES Acatlán?', - tipo: 'Cerrada', - opciones: [{ valor: 'Si' }, { valor: 'No' }], - obligatoria: true, - }, - { - titulo: 'Correo electrónico', - 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', - obligatoria: true, - tipo: 'Cerrada', - opciones: [ - { valor: 'Masculino' }, - { valor: 'Femenino' }, - { valor: 'Prefiero no decirlo' }, - ], - }, - { - titulo: 'Institución de procedencia', - tipo: 'Abierta', - obligatoria: true, - limite: 250, - }, - ], - }, - ], - }; \ No newline at end of file diff --git a/utils/get_formulario_feria.ts b/utils/get_formulario_feria.ts deleted file mode 100644 index e84abf2..0000000 --- a/utils/get_formulario_feria.ts +++ /dev/null @@ -1,193 +0,0 @@ -export const cuestionario_feria = { - 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: 2, - descripcion: 'Preguntas generales', - titulo: 'General', - }, - preguntas: [ - { - id_seccion_pregunta: 1000, - posicion: 1, - pregunta: { - id_pregunta: 10000, - pregunta: '¿Cómo calificaría nuestro servicio?', - contador_opcion: 4, - obligatoria: true, - id_tipo_pregunta: 1, - tipo_pregunta: { - id_tipo: 1, - tipo_pregunta: 'Cerrada', - }, - opciones: [ - { - id_pregunta_opcion: 50000, - posicion: 1, - id_opcion: 90000, - opcion: { - id_opcion: 90000, - opcion: 'Excelente', - }, - }, - { - id_pregunta_opcion: 50001, - posicion: 2, - id_opcion: 90001, - opcion: { - id_opcion: 90001, - opcion: 'Bueno', - }, - }, - { - id_pregunta_opcion: 50002, - posicion: 3, - id_opcion: 90002, - opcion: { - id_opcion: 90002, - opcion: 'Regular', - }, - }, - { - id_pregunta_opcion: 50003, - posicion: 4, - id_opcion: 90003, - opcion: { - id_opcion: 90003, - opcion: 'Malo', - }, - }, - ], - }, - }, - { - id_seccion_pregunta: 1001, - posicion: 2, - pregunta: { - id_pregunta: 10001, - pregunta: '¿Recomendaría nuestro servicio?', - contador_opcion: 2, - obligatoria: true, - id_tipo_pregunta: 1, - tipo_pregunta: { - id_tipo: 1, - tipo_pregunta: 'Cerrada', - }, - opciones: [ - { - id_pregunta_opcion: 50004, - posicion: 1, - id_opcion: 90004, - opcion: { - id_opcion: 90004, - opcion: 'Sí', - }, - }, - { - id_pregunta_opcion: 50005, - posicion: 2, - id_opcion: 90005, - opcion: { - id_opcion: 90005, - opcion: 'No', - }, - }, - ], - }, - }, - ], - }, - { - id_cuestionario_seccion: 2, - posicion: 2, - seccion: { - id_seccion: 101, - contador_pregunta: 1, - descripcion: 'Preguntas adicionales para conocer más detalles', - titulo: 'Adicionales', - }, - preguntas: [ - { - id_seccion_pregunta: 1002, - posicion: 1, - pregunta: { - id_pregunta: 10002, - pregunta: '¿Qué mejorarías en nuestro servicio?', - contador_opcion: 0, - obligatoria: false, - id_tipo_pregunta: 2, - tipo_pregunta: { - id_tipo: 2, - tipo_pregunta: 'Abierta', - }, - opciones: [], - }, - }, - { - id_seccion_pregunta: 1003, - posicion: 3, - pregunta: { - id_pregunta: 10003, - pregunta: '¿Qué aspectos del servicio fueron de tu agrado?', - contador_opcion: 3, - obligatoria: false, - id_tipo_pregunta: 3, // Puedes usar este ID para distinguir múltiples - tipo_pregunta: { - id_tipo: 3, - tipo_pregunta: 'Multiple', - }, - opciones: [ - { - id_pregunta_opcion: 50006, - posicion: 1, - id_opcion: 90006, - opcion: { - id_opcion: 90006, - opcion: 'Rapidez', - }, - }, - { - id_pregunta_opcion: 50007, - posicion: 2, - id_opcion: 90007, - opcion: { - id_opcion: 90007, - opcion: 'Atención al cliente', - }, - }, - { - id_pregunta_opcion: 50008, - posicion: 3, - id_opcion: 90008, - opcion: { - id_opcion: 90008, - opcion: 'Facilidad de uso', - }, - }, - ], - }, - }, - ], - }, - ], - }, - }; \ No newline at end of file diff --git a/utils/load-form.js b/utils/load-form.js deleted file mode 100644 index 9489b53..0000000 --- a/utils/load-form.js +++ /dev/null @@ -1,46 +0,0 @@ -// Script para cargar un formulario desde un archivo JSON - -const axios = require('axios'); -const fs = require('fs'); -const path = require('path'); - -const API_URL = 'http://localhost:4200'; - -async function loadFormFromFile(filePath) { - try { - // Leer el archivo JSON - const formData = require(filePath); - - console.log('Enviando datos del formulario al servidor...'); - console.log('Datos:', JSON.stringify(formData, null, 2)); - - // Enviar los datos al endpoint - const response = await axios.post(`${API_URL}/cuestionario`, formData); - - console.log('Formulario creado exitosamente:'); - console.log(JSON.stringify(response.data, null, 2)); - - return response.data; - } catch (error) { - console.error('Error al crear el formulario:'); - if (error.response) { - console.error('Status:', error.response.status); - console.error('Data:', JSON.stringify(error.response.data, null, 2)); - } else { - console.error(error.message); - } - throw error; - } -} - -// Verificar si se proporcionó un argumento de archivo -const args = process.argv.slice(2); -const filePath = args[0] || './crear_formulario_feria'; - -// Ejecutar la función -loadFormFromFile(filePath) - .then(() => console.log('Proceso completado')) - .catch(() => { - console.log('Proceso fallido'); - process.exit(1); - }); \ No newline at end of file diff --git a/utils/mandar_formulario.json b/utils/mandar_formulario.json deleted file mode 100644 index e69de29..0000000 diff --git a/utils/respuesta_1.json b/utils/respuesta_1.json deleted file mode 100644 index e69de29..0000000 diff --git a/utils/respuesta_2.json b/utils/respuesta_2.json deleted file mode 100644 index e69de29..0000000 diff --git a/utils/respuesta_formulario_feria.ts b/utils/respuesta_formulario_feria.ts deleted file mode 100644 index 0a2b5fe..0000000 --- a/utils/respuesta_formulario_feria.ts +++ /dev/null @@ -1,21 +0,0 @@ -const comunidad = { - id_formulario: 1, - respuestas: [ - { id_pregunta: 101, valor: 'Si' }, // ¿Eres parte de la comunidad de la FES Acatlán? - { id_pregunta: 102, valor: '123456789' }, // Numero de cuenta - { id_pregunta: 106, valor: 'Prefiero no decirlo' }, - ], - fecha_envio: '2025-04-02T13:45:00', - }; - - const externos = { - id_formulario: 1, - respuestas: [ - { id_pregunta: 101, valor: 'No' }, // ¿Eres parte de la comunidad de la FES Acatlán? - { id_pregunta: 103, valor: 'Juan Carlos' }, // Nombre(s) - { id_pregunta: 104, valor: 'Ramírez López' }, // Apellidos - { id_pregunta: 106, valor: 'Prefiero no decirlo' }, // genero - { id_pregunta: 107, valor: 'UAM Azcapotzalco' }, // Institución de procedencia - ], - fecha_envio: '2025-04-02T13:45:00', - }; \ No newline at end of file diff --git a/utils/test-crear-formulario.ts b/utils/test-crear-formulario.ts deleted file mode 100644 index 62b9ada..0000000 --- a/utils/test-crear-formulario.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { feriaSexualidad } from './crear_formulario_feria'; -import axios from 'axios'; - -// Asumiendo que el servidor está corriendo en localhost:4200 -const API_URL = 'http://localhost:4200'; - -async function crearFormulario() { - try { - const response = await axios.post(`${API_URL}/cuestionario`, feriaSexualidad); - console.log('Formulario creado exitosamente:'); - console.log(JSON.stringify(response.data, null, 2)); - return response.data; - } catch (error) { - console.error('Error al crear el formulario:'); - if (error.response) { - console.error('Status:', error.response.status); - console.error('Data:', error.response.data); - } else { - console.error(error.message); - } - throw error; - } -} - -// Ejecutar la función -crearFormulario() - .then(() => console.log('Proceso completado')) - .catch(() => console.log('Proceso fallido')); \ No newline at end of file -- 2.43.0 From d188d8bea32c51c529564458d20e37530474b45c Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 16 Jun 2025 18:24:56 -0600 Subject: [PATCH 04/14] Refactor cuestionario service and event handling; update validation types and remove unused console logs; add new event retrieval methods --- src/cuestionario/cuestionario.service.ts | 4 +- .../docs/cuestionario.examples.ts | 5 ++- .../entities/cuestionario.entity.ts | 4 +- .../cuestionario_respondido.service.ts | 15 +++---- src/evento/evento.controller.ts | 19 ++++++-- src/evento/evento.service.ts | 42 ++++++++++++++++++ .../participante_evento.service.ts | 1 - src/pregunta/entities/pregunta.entity.ts | 4 ++ .../banner-1750103308438-995542211.jpeg | Bin 76323 -> 0 bytes ...eg => banner-1750119255813-528833393.jpeg} | Bin 10 files changed, 76 insertions(+), 18 deletions(-) delete mode 100644 uploads/banners/banner-1750103308438-995542211.jpeg rename uploads/banners/{banner-1750103174963-705371719.jpeg => banner-1750119255813-528833393.jpeg} (100%) diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index 01aad4f..011fb4a 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -488,8 +488,6 @@ export class CuestionarioService { }, })); - console.log(opcionesFormateadas); - // Construir objeto de pregunta según el formato requerido return { id_seccion_pregunta: sp.id_seccion_pregunta, @@ -536,6 +534,8 @@ export class CuestionarioService { ? { id_evento: cuestionario.evento.id_evento, nombre_evento: cuestionario.evento.nombre_evento, + descripcion_evento: cuestionario.evento.descripcion_evento, + banner: cuestionario.evento.banner, tipo_evento: cuestionario.evento.tipo_evento, fecha_inicio: cuestionario.evento.fecha_inicio ? cuestionario.evento.fecha_inicio.toISOString() diff --git a/src/cuestionario/docs/cuestionario.examples.ts b/src/cuestionario/docs/cuestionario.examples.ts index 10fb0a7..5bc4214 100644 --- a/src/cuestionario/docs/cuestionario.examples.ts +++ b/src/cuestionario/docs/cuestionario.examples.ts @@ -46,12 +46,13 @@ export const ejemploCuestionarioAlumno = { titulo: 'Apellidos', tipo: 'Abierta (Respuesta corta)', obligatoria: true, - validacion: TiposValidacion.NOMBRE, + validacion: TiposValidacion.APELLIDOS, }, { titulo: 'Género', tipo: 'Cerrada', obligatoria: true, + validacion: TiposValidacion.GENERO, opciones: [ { valor: 'Masculino' }, { valor: 'Femenino' }, @@ -62,11 +63,13 @@ export const ejemploCuestionarioAlumno = { titulo: 'Institución de procedencia', tipo: 'Abierta (Respuesta corta)', obligatoria: true, + validacion: TiposValidacion.INSTITUCION, }, { titulo: 'Carrera', tipo: 'Abierta (Respuesta corta)', obligatoria: true, + validacion: TiposValidacion.CARRERA, }, ], }, diff --git a/src/cuestionario/entities/cuestionario.entity.ts b/src/cuestionario/entities/cuestionario.entity.ts index ca73447..ee2f068 100644 --- a/src/cuestionario/entities/cuestionario.entity.ts +++ b/src/cuestionario/entities/cuestionario.entity.ts @@ -52,7 +52,9 @@ export class Cuestionario { @JoinColumn({ name: 'id_evento' }) evento?: Evento; - @ManyToOne(() => TipoCuestionario, (tipo) => tipo.cuestionarios) + @ManyToOne(() => TipoCuestionario, (tipo) => tipo.cuestionarios, { + eager: true, + }) @JoinColumn({ name: 'id_tipo_cuestionario' }) tipoCuestionario: TipoCuestionario; diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index e7677e0..f702a1d 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -79,9 +79,11 @@ export class CuestionarioRespondidoService { }, ); if (cuestionarioRespondidoExistente) { - throw new BadRequestException( - `El participante con correo ${submitDto.correo} ya ha respondido el cuestionario ${cuestionario.nombre_form}.`, - ); + await queryRunner.rollbackTransaction(); + return { + success: false, + message: `El participante con correo ${submitDto.correo} ya ha respondido el cuestionario ${cuestionario.nombre_form}.`, + }; } // 3. Crear un nuevo registro de cuestionario respondido @@ -192,8 +194,6 @@ export class CuestionarioRespondidoService { id_cuestionario: number; } | null = null; - console.log(cuestionario.evento); - // Capturamos los datos para el QR if (cuestionario.evento && cuestionario.evento.id_evento) { datosQR = { @@ -412,11 +412,6 @@ export class CuestionarioRespondidoService { [id], ); - console.log( - 'Respuestas cerradas SQL:', - JSON.stringify(respuestasCerradas, null, 2), - ); - // Transformar las respuestas cerradas al formato deseado const respuestasCerradasFormateadas = respuestasCerradas.map( (respuesta) => ({ diff --git a/src/evento/evento.controller.ts b/src/evento/evento.controller.ts index 60cdb66..d77a7a4 100644 --- a/src/evento/evento.controller.ts +++ b/src/evento/evento.controller.ts @@ -35,9 +35,9 @@ export class EventoController { return this.eventoService.getEventos(); } - @Get(':id/cuestionarios') - getCuestionarios(@Param('id', ParseIntPipe) id: number) { - return this.eventoService.getCuestionariosEvento(id); + @Get('activos/cuestionarios') + getCuestionariosActivos() { + return this.eventoService.getEventosActivosCuestionarios(); } @Get('activos') @@ -45,6 +45,19 @@ export class EventoController { return this.eventoService.getEventosActivos(); } + @Get(':id/cuestionarios') + getCuestionarios(@Param('id', ParseIntPipe) id: number) { + return this.eventoService.getCuestionariosEvento(id); + } + + @Get(':id_evento/cuestionario/:id_cuestionario') + getCuestionarioEvento( + @Param('id_evento', ParseIntPipe) id_evento: number, + @Param('id_cuestionario', ParseIntPipe) id_cuestionario: number, + ) { + return this.eventoService.getCuestionarioEvento(id_evento, id_cuestionario); + } + @Post(':id/banner') @UseInterceptors( FileInterceptor('banner', { diff --git a/src/evento/evento.service.ts b/src/evento/evento.service.ts index 47d942f..ea2199d 100644 --- a/src/evento/evento.service.ts +++ b/src/evento/evento.service.ts @@ -69,6 +69,17 @@ export class EventoService { }); } + async getEventosActivosCuestionarios() { + const eventos = await this.eventoRepository.find({ + where: { + fecha_fin: MoreThan(new Date()), + }, + relations: ['cuestionarios'], // solo los cuestionarios, sin secciones + }); + + return eventos; + } + async getEventoOrFail(id_evento: number): Promise { const eventoFound = await this.eventoRepository.findOne({ where: { @@ -113,4 +124,35 @@ export class EventoService { }, }); } + + async getCuestionarioEvento( + id_evento: number, + id_cuestionario: number, + ) { + const evento = await this.eventoRepository.findOne({ + where: { id_evento }, + relations: ['cuestionarios'], + }); + + if (!evento) { + throw new NotFoundException(`Evento con ID ${id_evento} no encontrado.`); + } + + const cuestionario = evento.cuestionarios.find( + (c) => c.id_cuestionario === id_cuestionario, + ); + + if (!cuestionario) { + throw new NotFoundException( + `Cuestionario no asignado al evento ${evento.nombre_evento}.`, + ); + } + + // Return evento info, but only with the searched cuestionario + return { + ...evento, + cuestionarios: undefined, + cuestionario: cuestionario, + }; + } } diff --git a/src/participante_evento/participante_evento.service.ts b/src/participante_evento/participante_evento.service.ts index f7808c5..83b8f74 100644 --- a/src/participante_evento/participante_evento.service.ts +++ b/src/participante_evento/participante_evento.service.ts @@ -90,7 +90,6 @@ export class ParticipanteEventoService { async getParticipantesPorCuestionario(id_cuestionario: number) { const cuestionario = await this.cuestionarioService.getCuestionarioOrFail(id_cuestionario); - console.log(cuestionario); const participantesEvento = await this.participanteEventoRepository.find({ where: { id_cuestionario }, diff --git a/src/pregunta/entities/pregunta.entity.ts b/src/pregunta/entities/pregunta.entity.ts index 15c46a0..8b54cc8 100644 --- a/src/pregunta/entities/pregunta.entity.ts +++ b/src/pregunta/entities/pregunta.entity.ts @@ -16,12 +16,16 @@ export enum TiposValidacion { CORREO_INSTITUCIONAL = 'correo_institucional', TELEFONO = 'telefono', NOMBRE = 'nombre', + APELLIDOS = 'apellidos', ENTERO = 'entero', DECIMAL = 'decimal', COMUNIDAD_ALUMNO = 'comunidad_alumno', CUENTA_ALUMNO = 'cuenta_alumno', COMUNIDAD_TRABAJADOR = 'comunidad_trabajador', CUENTA_TRABAJADOR = 'cuenta_trabajador', + GENERO = 'genero', + CARRERA = 'carrera', + INSTITUCION = 'institucion', } @Entity() diff --git a/uploads/banners/banner-1750103308438-995542211.jpeg b/uploads/banners/banner-1750103308438-995542211.jpeg deleted file mode 100644 index b99380a7c3c8eb37bf8fcb493954ddcf8ff355e8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 76323 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

rYp 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 bbzwsjntIwT{U`URL!O*s`{!HmFO&ZVdw%md diff --git a/uploads/banners/banner-1750103174963-705371719.jpeg b/uploads/banners/banner-1750119255813-528833393.jpeg similarity index 100% rename from uploads/banners/banner-1750103174963-705371719.jpeg rename to uploads/banners/banner-1750119255813-528833393.jpeg -- 2.43.0 From 7d8c9e9ef49f32d4a86543abfdd5086c1fb4042c Mon Sep 17 00:00:00 2001 From: miguel Date: Wed, 18 Jun 2025 10:59:06 -0600 Subject: [PATCH 05/14] Add Trabajadores module with controller, service, DTOs, and entity; integrate with TypeORM --- src/app.module.ts | 2 + src/db/typeorm.config.ts | 51 +++++++++++++++++-- .../dto/create-trabajadore.dto.ts | 1 + .../dto/update-trabajadore.dto.ts | 4 ++ .../entities/trabajadore.entity.ts | 22 ++++++++ src/trabajadores/trabajadores.controller.ts | 34 +++++++++++++ src/trabajadores/trabajadores.module.ts | 13 +++++ src/trabajadores/trabajadores.service.ts | 26 ++++++++++ 8 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 src/trabajadores/dto/create-trabajadore.dto.ts create mode 100644 src/trabajadores/dto/update-trabajadore.dto.ts create mode 100644 src/trabajadores/entities/trabajadore.entity.ts create mode 100644 src/trabajadores/trabajadores.controller.ts create mode 100644 src/trabajadores/trabajadores.module.ts create mode 100644 src/trabajadores/trabajadores.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 070febc..649d94d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -27,6 +27,7 @@ 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'; +import { TrabajadoresModule } from './trabajadores/trabajadores.module'; @Module({ imports: [ @@ -65,6 +66,7 @@ import { AlumnosModule } from './alumnos/alumnos.module'; TipoUserModule, ValidacionesModule, QrModule, + TrabajadoresModule, ], controllers: [AppController], diff --git a/src/db/typeorm.config.ts b/src/db/typeorm.config.ts index e893aa4..8748b60 100644 --- a/src/db/typeorm.config.ts +++ b/src/db/typeorm.config.ts @@ -1,6 +1,25 @@ import { ConfigService } from '@nestjs/config'; import { TypeOrmModuleOptions } from '@nestjs/typeorm'; import { RegistroAlumno } from '../alumnos/entities/registro-alumno.entity'; +import { RegistroTrabajador } from 'src/trabajadores/entities/trabajadore.entity'; +import { Administrador } from 'src/administrador/entities/administrador.entity'; +import { Asistencia } from 'src/asistencia/entities/asistencia.entity'; +import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity'; +import { CuestionarioRespondido } from 'src/cuestionario_respondido/entities/cuestionario_respondido.entity'; +import { CuestionarioSeccion } from 'src/cuestionario_seccion/entities/cuestionario_seccion.entity'; +import { Evento } from 'src/evento/entities/evento.entity'; +import { Opcion } from 'src/opcion/entities/opcion.entity'; +import { Participante } from 'src/participante/entities/participante.entity'; +import { ParticipanteEvento } from 'src/participante_evento/entities/participante_evento.entity'; +import { Pregunta } from 'src/pregunta/entities/pregunta.entity'; +import { PreguntaOpcion } from 'src/pregunta_opcion/entities/pregunta_opcion.entity'; +import { RespuestaParticipanteAbierta } from 'src/respuesta_participante_abierta/entities/respuesta_participante_abierta.entity'; +import { RespuestaParticipanteCerrada } from 'src/respuesta_participante_cerrada/entities/respuesta_participante_cerrada.entity'; +import { Seccion } from 'src/seccion/entities/seccion.entity'; +import { SeccionPregunta } from 'src/seccion_pregunta/entities/seccion_pregunta.entity'; +import { TipoCuestionario } from 'src/tipo_cuestionario/entities/tipo_cuestionario.entity'; +import { TipoPregunta } from 'src/tipo_pregunta/entities/tipo_pregunta.entity'; +import { TipoUser } from 'src/tipo_user/entities/tipo_user.entity'; export const createMainDbConfig = async ( configService: ConfigService, @@ -11,11 +30,31 @@ export const createMainDbConfig = async ( username: configService.get('DB_USERNAME'), password: configService.get('DB_PASSWORD'), database: configService.get('DB_DATABASE'), - entities: [__dirname + '/../**/*.entity{.ts,.js}'], - autoLoadEntities: true, - synchronize: true, + entities: [ + Administrador, + Asistencia, + Cuestionario, + CuestionarioRespondido, + CuestionarioSeccion, + Evento, + Opcion, + Participante, + ParticipanteEvento, + Pregunta, + PreguntaOpcion, + RespuestaParticipanteAbierta, + RespuestaParticipanteCerrada, + Seccion, + SeccionPregunta, + TipoCuestionario, + TipoPregunta, + TipoUser + ], retryAttempts: 5, retryDelay: 3000, + connectTimeout: 30000, + + synchronize: false, dropSchema: false, }); @@ -29,9 +68,11 @@ export const createAlumnosDbConfig = async ( username: configService.get('ALUMNOS_DB_USERNAME'), password: configService.get('ALUMNOS_DB_PASSWORD'), database: configService.get('ALUMNOS_DB_DATABASE'), - entities: [RegistroAlumno], - synchronize: false, + entities: [RegistroAlumno, RegistroTrabajador], retryAttempts: 10, retryDelay: 3000, connectTimeout: 30000, + + synchronize: false, + dropSchema: false, }); diff --git a/src/trabajadores/dto/create-trabajadore.dto.ts b/src/trabajadores/dto/create-trabajadore.dto.ts new file mode 100644 index 0000000..1219a60 --- /dev/null +++ b/src/trabajadores/dto/create-trabajadore.dto.ts @@ -0,0 +1 @@ +export class CreateTrabajadoreDto {} diff --git a/src/trabajadores/dto/update-trabajadore.dto.ts b/src/trabajadores/dto/update-trabajadore.dto.ts new file mode 100644 index 0000000..dd69e6d --- /dev/null +++ b/src/trabajadores/dto/update-trabajadore.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateTrabajadoreDto } from './create-trabajadore.dto'; + +export class UpdateTrabajadoreDto extends PartialType(CreateTrabajadoreDto) {} diff --git a/src/trabajadores/entities/trabajadore.entity.ts b/src/trabajadores/entities/trabajadore.entity.ts new file mode 100644 index 0000000..a2d1206 --- /dev/null +++ b/src/trabajadores/entities/trabajadore.entity.ts @@ -0,0 +1,22 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity('registro_trabajador') +export class RegistroTrabajador { + @PrimaryGeneratedColumn() + id_trabajador: number; + + @Column({ type: 'varchar', length: 20, unique: true }) + numero_trabajador: string; + + @Column({ type: 'varchar', length: 13, unique: true }) + rfc: string; + + @Column({ type: 'varchar', length: 100 }) + nombre: string; + + @Column({ type: 'varchar', length: 100 }) + apellidos: string; + + @Column({ type: 'char', length: 1 }) + genero: string; +} diff --git a/src/trabajadores/trabajadores.controller.ts b/src/trabajadores/trabajadores.controller.ts new file mode 100644 index 0000000..ab41cdb --- /dev/null +++ b/src/trabajadores/trabajadores.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; +import { TrabajadoresService } from './trabajadores.service'; +import { CreateTrabajadoreDto } from './dto/create-trabajadore.dto'; +import { UpdateTrabajadoreDto } from './dto/update-trabajadore.dto'; + +@Controller('trabajadores') +export class TrabajadoresController { + constructor(private readonly trabajadoresService: TrabajadoresService) {} + + @Post() + create(@Body() createTrabajadoreDto: CreateTrabajadoreDto) { + return this.trabajadoresService.create(createTrabajadoreDto); + } + + @Get() + findAll() { + return this.trabajadoresService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.trabajadoresService.findOne(+id); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() updateTrabajadoreDto: UpdateTrabajadoreDto) { + return this.trabajadoresService.update(+id, updateTrabajadoreDto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.trabajadoresService.remove(+id); + } +} diff --git a/src/trabajadores/trabajadores.module.ts b/src/trabajadores/trabajadores.module.ts new file mode 100644 index 0000000..7f7d44c --- /dev/null +++ b/src/trabajadores/trabajadores.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TrabajadoresService } from './trabajadores.service'; +import { TrabajadoresController } from './trabajadores.controller'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RegistroTrabajador } from './entities/trabajadore.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([RegistroTrabajador], 'alumnosConnection')], + + controllers: [TrabajadoresController], + providers: [TrabajadoresService], +}) +export class TrabajadoresModule {} diff --git a/src/trabajadores/trabajadores.service.ts b/src/trabajadores/trabajadores.service.ts new file mode 100644 index 0000000..13a1aef --- /dev/null +++ b/src/trabajadores/trabajadores.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { CreateTrabajadoreDto } from './dto/create-trabajadore.dto'; +import { UpdateTrabajadoreDto } from './dto/update-trabajadore.dto'; + +@Injectable() +export class TrabajadoresService { + create(createTrabajadoreDto: CreateTrabajadoreDto) { + return 'This action adds a new trabajadore'; + } + + findAll() { + return `This action returns all trabajadores`; + } + + findOne(id: number) { + return `This action returns a #${id} trabajadore`; + } + + update(id: number, updateTrabajadoreDto: UpdateTrabajadoreDto) { + return `This action updates a #${id} trabajadore`; + } + + remove(id: number) { + return `This action removes a #${id} trabajadore`; + } +} -- 2.43.0 From 6c85847bf45cc55bdd0f7142b5ed11e45513fd4b Mon Sep 17 00:00:00 2001 From: miguel Date: Wed, 18 Jun 2025 11:00:44 -0600 Subject: [PATCH 06/14] synhronize extra db --- src/db/typeorm.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/typeorm.config.ts b/src/db/typeorm.config.ts index 8748b60..a6036d3 100644 --- a/src/db/typeorm.config.ts +++ b/src/db/typeorm.config.ts @@ -73,6 +73,6 @@ export const createAlumnosDbConfig = async ( retryDelay: 3000, connectTimeout: 30000, - synchronize: false, + synchronize: true, dropSchema: false, }); -- 2.43.0 From 282f921e4362d52a7b8a55b7d66834a4de98a661 Mon Sep 17 00:00:00 2001 From: miguel Date: Wed, 18 Jun 2025 12:41:30 -0600 Subject: [PATCH 07/14] Refactor code structure for improved readability and maintainability --- .../docs/cuestionario.examples.ts | 6 ++-- .../cuestionario_respondido.service.ts | 12 +++---- src/evento/dto/update.evento.dto.ts | 6 +++- src/evento/evento.controller.ts | 5 ++- src/evento/evento.service.ts | 31 ++++++++++++++---- .../participante_evento.service.ts | 2 +- src/pregunta/entities/pregunta.entity.ts | 1 + .../entities/trabajadore.entity.ts | 2 +- ...eg => banner-1750269883742-220110679.jpeg} | Bin 9 files changed, 46 insertions(+), 19 deletions(-) rename uploads/banners/{banner-1750119255813-528833393.jpeg => banner-1750269883742-220110679.jpeg} (100%) diff --git a/src/cuestionario/docs/cuestionario.examples.ts b/src/cuestionario/docs/cuestionario.examples.ts index 5bc4214..0e4010c 100644 --- a/src/cuestionario/docs/cuestionario.examples.ts +++ b/src/cuestionario/docs/cuestionario.examples.ts @@ -102,13 +102,13 @@ export const ejemploCuestionarioTrabajador = { validacion: TiposValidacion.COMUNIDAD_TRABAJADOR, }, { - titulo: 'Número de trabajador', + titulo: 'RFC', tipo: 'Abierta (Respuesta corta)', obligatoria: false, - validacion: TiposValidacion.CUENTA_TRABAJADOR, + validacion: TiposValidacion.RFC, }, { - titulo: 'Correo electrónico institucional', + titulo: 'Correo electrónico', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: TiposValidacion.CORREO_INSTITUCIONAL, diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index f702a1d..ed33e89 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -450,7 +450,7 @@ export class CuestionarioRespondidoService { return `This action removes a #${id} cuestionarioRespondido`; } - async generarReporteRespuestas(idCuestionario: number): Promise { + /* async generarReporteRespuestas(idCuestionario: number): Promise { const cuestionario = await this.cuestionarioRepository.findOne({ where: { id_cuestionario: idCuestionario }, relations: ['evento'], @@ -543,9 +543,9 @@ export class CuestionarioRespondidoService { } return csv; - } + } */ - /* async generarReporteRespuestas(idCuestionario: number): Promise { + async generarReporteRespuestas(idCuestionario: number): Promise { // Buscar el cuestionario para validar que existe const cuestionario = await this.cuestionarioRepository.findOne({ where: { id_cuestionario: idCuestionario }, @@ -586,12 +586,12 @@ export class CuestionarioRespondidoService { pe.fecha_asistencia FROM cuestionario_respondido cr JOIN participante p ON cr.id_participante = p.id_participante - LEFT JOIN participante_evento pe ON p.id_participante = pe.id_participante AND pe.id_evento = ? + LEFT JOIN participante_evento pe ON p.id_participante = pe.id_participante AND pe.id_cuestionario = ? WHERE cr.id_cuestionario = ? ORDER BY cr.fecha_respuesta DESC `, [ - cuestionario.evento ? cuestionario.evento.id_evento : null, + cuestionario.evento ? cuestionario.id_cuestionario : null, idCuestionario, ], ); @@ -711,5 +711,5 @@ export class CuestionarioRespondidoService { .join('\n'); return csvContent; - } */ + } } diff --git a/src/evento/dto/update.evento.dto.ts b/src/evento/dto/update.evento.dto.ts index 8f89f69..d567e22 100644 --- a/src/evento/dto/update.evento.dto.ts +++ b/src/evento/dto/update.evento.dto.ts @@ -8,9 +8,13 @@ export class UpdateEventoDto { tipo_evento?: string; @IsString() - @IsNotEmpty({ message: 'El nombre del evento es obligatorio' }) + @IsOptional() nombre_evento: string; + @IsString() + @IsOptional() + descripcion_evento: string; + @IsOptional() @Type(() => Date) @IsDate({ message: 'La fecha de inicio debe ser una fecha válida' }) diff --git a/src/evento/evento.controller.ts b/src/evento/evento.controller.ts index d77a7a4..f202559 100644 --- a/src/evento/evento.controller.ts +++ b/src/evento/evento.controller.ts @@ -7,6 +7,7 @@ import { ParseIntPipe, Patch, Post, + UnprocessableEntityException, UploadedFile, UseInterceptors, } from '@nestjs/common'; @@ -87,7 +88,9 @@ export class EventoController { @UploadedFile() file: Express.Multer.File, ) { if (!file) { - throw new Error('No se ha recibido un archivo válido'); + throw new UnprocessableEntityException( + 'No se ha subido ningún archivo o el archivo no es válido.', + ); } return this.eventoService.asociarBanner(id, file.filename); diff --git a/src/evento/evento.service.ts b/src/evento/evento.service.ts index ea2199d..a50ce57 100644 --- a/src/evento/evento.service.ts +++ b/src/evento/evento.service.ts @@ -9,6 +9,8 @@ import { MoreThan, Repository } from 'typeorm'; import { Evento } from './entities/evento.entity'; import { CreateEventoDto } from './dto/create-evento.dto'; import { UpdateEventoDto } from './dto/update.evento.dto'; +import { join } from 'path'; +import { existsSync, unlinkSync } from 'fs'; @Injectable() export class EventoService { @@ -40,6 +42,26 @@ export class EventoService { async asociarBanner(id: number, filename: string) { const evento = await this.getEventoOrFail(id); + // Si ya tiene un banner anterior, eliminarlo + if (evento.banner) { + const bannerPath = join( + __dirname, + '..', + '..', + 'uploads', + 'banners', + evento.banner, + ); + if (existsSync(bannerPath)) { + try { + unlinkSync(bannerPath); + } catch (err) { + console.error(`Error al eliminar el banner anterior: ${err.message}`); + } + } + } + + // Asociar el nuevo banner evento.banner = filename; return this.eventoRepository.save(evento); } @@ -76,9 +98,9 @@ export class EventoService { }, relations: ['cuestionarios'], // solo los cuestionarios, sin secciones }); - + return eventos; - } + } async getEventoOrFail(id_evento: number): Promise { const eventoFound = await this.eventoRepository.findOne({ @@ -125,10 +147,7 @@ export class EventoService { }); } - async getCuestionarioEvento( - id_evento: number, - id_cuestionario: number, - ) { + async getCuestionarioEvento(id_evento: number, id_cuestionario: number) { const evento = await this.eventoRepository.findOne({ where: { id_evento }, relations: ['cuestionarios'], diff --git a/src/participante_evento/participante_evento.service.ts b/src/participante_evento/participante_evento.service.ts index 83b8f74..2b1d71e 100644 --- a/src/participante_evento/participante_evento.service.ts +++ b/src/participante_evento/participante_evento.service.ts @@ -72,7 +72,7 @@ export class ParticipanteEventoService { id_participante, id_cuestionario: id_evento, }, - relations: ['evento', 'participante'], + relations: ['participante'], }); if (!participante_eventoFound) { diff --git a/src/pregunta/entities/pregunta.entity.ts b/src/pregunta/entities/pregunta.entity.ts index 8b54cc8..4ba1d32 100644 --- a/src/pregunta/entities/pregunta.entity.ts +++ b/src/pregunta/entities/pregunta.entity.ts @@ -26,6 +26,7 @@ export enum TiposValidacion { GENERO = 'genero', CARRERA = 'carrera', INSTITUCION = 'institucion', + RFC = 'rfc', } @Entity() diff --git a/src/trabajadores/entities/trabajadore.entity.ts b/src/trabajadores/entities/trabajadore.entity.ts index a2d1206..75f1df8 100644 --- a/src/trabajadores/entities/trabajadore.entity.ts +++ b/src/trabajadores/entities/trabajadore.entity.ts @@ -5,7 +5,7 @@ export class RegistroTrabajador { @PrimaryGeneratedColumn() id_trabajador: number; - @Column({ type: 'varchar', length: 20, unique: true }) + @Column({ type: 'varchar', length: 20, unique: true, nullable: true }) numero_trabajador: string; @Column({ type: 'varchar', length: 13, unique: true }) diff --git a/uploads/banners/banner-1750119255813-528833393.jpeg b/uploads/banners/banner-1750269883742-220110679.jpeg similarity index 100% rename from uploads/banners/banner-1750119255813-528833393.jpeg rename to uploads/banners/banner-1750269883742-220110679.jpeg -- 2.43.0 From 3d90c51742649dba0b5aa9f3135e982e6f4652ad Mon Sep 17 00:00:00 2001 From: miguel Date: Wed, 18 Jun 2025 12:57:52 -0600 Subject: [PATCH 08/14] cambios --- .gitignore | 3 +++ src/db/typeorm.config.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4b56acf..d91926b 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ pids # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Uploaded files +/uploads \ No newline at end of file diff --git a/src/db/typeorm.config.ts b/src/db/typeorm.config.ts index a6036d3..4d65cfa 100644 --- a/src/db/typeorm.config.ts +++ b/src/db/typeorm.config.ts @@ -54,7 +54,7 @@ export const createMainDbConfig = async ( retryDelay: 3000, connectTimeout: 30000, - synchronize: false, + synchronize: true, dropSchema: false, }); -- 2.43.0 From ae0313cd1f1bde82e1367684a782f6712522c26e Mon Sep 17 00:00:00 2001 From: miguel Date: Wed, 18 Jun 2025 15:52:37 -0600 Subject: [PATCH 09/14] drop database in restart --- src/db/typeorm.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/typeorm.config.ts b/src/db/typeorm.config.ts index 4d65cfa..ba41ad2 100644 --- a/src/db/typeorm.config.ts +++ b/src/db/typeorm.config.ts @@ -55,7 +55,7 @@ export const createMainDbConfig = async ( connectTimeout: 30000, synchronize: true, - dropSchema: false, + dropSchema: true, }); export const createAlumnosDbConfig = async ( -- 2.43.0 From 74de90eda86ae1fb62eeb518853443a9f6579476 Mon Sep 17 00:00:00 2001 From: miguel Date: Thu, 19 Jun 2025 18:36:29 -0600 Subject: [PATCH 10/14] Add xlsx support and enhance Trabajadores module with file upload functionality - Updated package.json and package-lock.json to include xlsx dependency. - Enhanced AlumnosService to return UsuarioData type. - Modified CuestionarioRespondidoService to include event start and end dates in responses. - Updated TrabajadoresController to add file upload endpoint for processing Excel files. - Implemented file processing logic in TrabajadoresService to handle worker registration from Excel. - Created TrabajadorApiDocumentation for API documentation of the new file upload feature. - Refactored RegistroTrabajador entity to include carrera field and changed num_trabajador type. --- package-lock.json | 106 +++++++++++++++++- package.json | 3 +- src/alumnos/alumnos.controller.ts | 4 +- src/alumnos/alumnos.service.ts | 21 +++- .../cuestionario_respondido.service.ts | 6 + src/emails/registro-evento.ts | 17 +++ src/trabajadores/docs/trabajadores.docs.ts | 30 +++++ .../entities/trabajadore.entity.ts | 7 +- src/trabajadores/trabajadores.controller.ts | 55 +++++++-- src/trabajadores/trabajadores.module.ts | 6 +- src/trabajadores/trabajadores.service.ts | 82 ++++++++++++++ 11 files changed, 317 insertions(+), 20 deletions(-) create mode 100644 src/trabajadores/docs/trabajadores.docs.ts diff --git a/package-lock.json b/package-lock.json index cd34f6e..bb96032 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,8 @@ "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "typeorm": "^0.3.21" + "typeorm": "^0.3.21", + "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -4085,6 +4086,15 @@ "node": ">=0.4.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4870,6 +4880,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5095,6 +5118,15 @@ "node": ">= 0.12.0" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -5300,6 +5332,18 @@ } } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -6697,6 +6741,15 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -10536,6 +10589,18 @@ "node": ">= 0.6" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -12126,6 +12191,24 @@ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "license": "ISC" }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -12237,6 +12320,27 @@ "dev": true, "license": "ISC" }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 89f9c62..f56500c 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "typeorm": "^0.3.21" + "typeorm": "^0.3.21", + "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/src/alumnos/alumnos.controller.ts b/src/alumnos/alumnos.controller.ts index 61b8a9c..44c406d 100644 --- a/src/alumnos/alumnos.controller.ts +++ b/src/alumnos/alumnos.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Param, NotFoundException } from '@nestjs/common'; -import { AlumnosService } from './alumnos.service'; +import { AlumnosService, UsuarioData } from './alumnos.service'; import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; import { AlumnoDto, ALUMNO_RESPONSES } from './alumnos.documentation'; @@ -14,7 +14,7 @@ export class AlumnosController { @ApiResponse(ALUMNO_RESPONSES[200]) @ApiResponse(ALUMNO_RESPONSES[404]) @ApiResponse(ALUMNO_RESPONSES[500]) - async getAlumnoByCuenta(@Param('cuenta') cuenta: string): Promise { + async getAlumnoByCuenta(@Param('cuenta') cuenta: string): Promise { const alumno = await this.alumnosService.findByCuenta(cuenta); if (!alumno) { throw new NotFoundException(`No se encontró alumno con número de cuenta ${cuenta}`); diff --git a/src/alumnos/alumnos.service.ts b/src/alumnos/alumnos.service.ts index bda130a..bef0f1a 100644 --- a/src/alumnos/alumnos.service.ts +++ b/src/alumnos/alumnos.service.ts @@ -3,6 +3,15 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { RegistroAlumno } from './entities/registro-alumno.entity'; +export type UsuarioData = { + cuenta: string | null; + nombre: string | null; + apellidos: string | null; + carrera: string | null; + genero: string | null; + rfc?: string | null; // Solo para trabajadores +} + @Injectable() export class AlumnosService { constructor( @@ -10,9 +19,17 @@ export class AlumnosService { private registroAlumnoRepository: Repository, ) {} - async findByCuenta(cuenta: string): Promise { - return this.registroAlumnoRepository.findOne({ + async findByCuenta(cuenta: string): Promise { + const alumno = await this.registroAlumnoRepository.findOne({ where: { id_ncuenta: parseInt(cuenta, 10) }, }); + + return { + cuenta: alumno?.id_ncuenta.toString() || null, + nombre: alumno?.nombre || null, + apellidos: alumno?.apellidos || null, + carrera: alumno?.carrera || null, + genero: alumno?.genero || null, + } } } diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index ed33e89..ad16345 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -261,6 +261,12 @@ export class CuestionarioRespondidoService { nombreEvento: cuestionario.evento?.nombre_evento, correo: participante.correo, fechaRegistro: new Date().toLocaleString(), + fechaInicioEvento: cuestionario.evento?.fecha_inicio + ? new Date(cuestionario.evento.fecha_inicio).toLocaleString() + : undefined, + fechaFinEvento: cuestionario.evento?.fecha_fin + ? new Date(cuestionario.evento.fecha_fin).toLocaleString() + : undefined, }), adjuntos: [ { diff --git a/src/emails/registro-evento.ts b/src/emails/registro-evento.ts index 1c23818..c533d66 100644 --- a/src/emails/registro-evento.ts +++ b/src/emails/registro-evento.ts @@ -3,12 +3,27 @@ export function generarHtmlCorreoAsistencia({ nombreEvento, correo, fechaRegistro, + fechaInicioEvento, + fechaFinEvento, }: { nombreForm: string; nombreEvento: string; correo: string; fechaRegistro: string; + fechaInicioEvento?: string; + fechaFinEvento?: string; }) { + const horarioEvento = fechaInicioEvento && fechaFinEvento + ? ` +

+

+ 🕒 Horario del evento:
+ ${fechaInicioEvento} al ${fechaFinEvento} +

+
+ ` + : ''; + return `

🎓 ¡Gracias por registrarte!

@@ -32,6 +47,8 @@ export function generarHtmlCorreoAsistencia({
  • Fecha de registro: ${fechaRegistro}
  • + ${horarioEvento} +

    Si tienes alguna duda, acude al módulo de información durante el evento.

    diff --git a/src/trabajadores/docs/trabajadores.docs.ts b/src/trabajadores/docs/trabajadores.docs.ts new file mode 100644 index 0000000..967720b --- /dev/null +++ b/src/trabajadores/docs/trabajadores.docs.ts @@ -0,0 +1,30 @@ +import { applyDecorators } from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiConsumes, + ApiBody, +} from '@nestjs/swagger'; + +export class TrabajadorApiDocumentation { + static ApiController = ApiTags('Trabajador'); + + static ApiCargarArchivo = applyDecorators( + ApiOperation({ summary: 'Cargar archivo Excel con trabajadores' }), + ApiConsumes('multipart/form-data'), + ApiBody({ + description: 'Archivo Excel (.xls o .xlsx) con columnas: rfc, num_empleado, nombre, adscripcion, correo, AP1, AP2, Nombres, sexo', + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + description: 'Archivo Excel con trabajadores a registrar', + }, + }, + required: ['file'], + }, + }), + ); +} diff --git a/src/trabajadores/entities/trabajadore.entity.ts b/src/trabajadores/entities/trabajadore.entity.ts index 75f1df8..4b4c415 100644 --- a/src/trabajadores/entities/trabajadore.entity.ts +++ b/src/trabajadores/entities/trabajadore.entity.ts @@ -5,8 +5,8 @@ export class RegistroTrabajador { @PrimaryGeneratedColumn() id_trabajador: number; - @Column({ type: 'varchar', length: 20, unique: true, nullable: true }) - numero_trabajador: string; + @Column({ type: 'int', unique: true, nullable: true }) + num_trabajador: number; @Column({ type: 'varchar', length: 13, unique: true }) rfc: string; @@ -19,4 +19,7 @@ export class RegistroTrabajador { @Column({ type: 'char', length: 1 }) genero: string; + + @Column({ type: 'varchar', length: 100 }) + carrera: string; } diff --git a/src/trabajadores/trabajadores.controller.ts b/src/trabajadores/trabajadores.controller.ts index ab41cdb..a10fe84 100644 --- a/src/trabajadores/trabajadores.controller.ts +++ b/src/trabajadores/trabajadores.controller.ts @@ -1,29 +1,64 @@ -import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + UseInterceptors, + UploadedFile, + NotFoundException, +} from '@nestjs/common'; import { TrabajadoresService } from './trabajadores.service'; import { CreateTrabajadoreDto } from './dto/create-trabajadore.dto'; import { UpdateTrabajadoreDto } from './dto/update-trabajadore.dto'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +import { extname } from 'path'; +import { TrabajadorApiDocumentation } from './docs/trabajadores.docs'; @Controller('trabajadores') +@TrabajadorApiDocumentation.ApiController export class TrabajadoresController { constructor(private readonly trabajadoresService: TrabajadoresService) {} + @Post('cargar') + @TrabajadorApiDocumentation.ApiCargarArchivo + @UseInterceptors( + FileInterceptor('file', { + storage: diskStorage({ + destination: './uploads', + filename: (_, file, callback) => { + const uniqueName = `${Date.now()}${extname(file.originalname)}`; + callback(null, uniqueName); + }, + }), + }), + ) + async cargarArchivo(@UploadedFile() file: Express.Multer.File) { + return this.trabajadoresService.procesarArchivo(file.path); + } + @Post() create(@Body() createTrabajadoreDto: CreateTrabajadoreDto) { return this.trabajadoresService.create(createTrabajadoreDto); } - @Get() - findAll() { - return this.trabajadoresService.findAll(); - } - - @Get(':id') - findOne(@Param('id') id: string) { - return this.trabajadoresService.findOne(+id); + @Get(':rfc') + findByRfc(@Param('rfc') rfc: string) { + const trabajador = this.trabajadoresService.findByRfc(rfc); + if (!trabajador) { + throw new NotFoundException(`No se encontró trabajador con RFC ${rfc}`); + } + return trabajador; } @Patch(':id') - update(@Param('id') id: string, @Body() updateTrabajadoreDto: UpdateTrabajadoreDto) { + update( + @Param('id') id: string, + @Body() updateTrabajadoreDto: UpdateTrabajadoreDto, + ) { return this.trabajadoresService.update(+id, updateTrabajadoreDto); } diff --git a/src/trabajadores/trabajadores.module.ts b/src/trabajadores/trabajadores.module.ts index 7f7d44c..d9f8855 100644 --- a/src/trabajadores/trabajadores.module.ts +++ b/src/trabajadores/trabajadores.module.ts @@ -5,9 +5,11 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { RegistroTrabajador } from './entities/trabajadore.entity'; @Module({ - imports: [TypeOrmModule.forFeature([RegistroTrabajador], 'alumnosConnection')], - + imports: [ + TypeOrmModule.forFeature([RegistroTrabajador], 'alumnosConnection'), + ], controllers: [TrabajadoresController], providers: [TrabajadoresService], + exports: [TrabajadoresService], }) export class TrabajadoresModule {} diff --git a/src/trabajadores/trabajadores.service.ts b/src/trabajadores/trabajadores.service.ts index 13a1aef..4aa8355 100644 --- a/src/trabajadores/trabajadores.service.ts +++ b/src/trabajadores/trabajadores.service.ts @@ -1,9 +1,91 @@ import { Injectable } from '@nestjs/common'; import { CreateTrabajadoreDto } from './dto/create-trabajadore.dto'; import { UpdateTrabajadoreDto } from './dto/update-trabajadore.dto'; +import { InjectRepository } from '@nestjs/typeorm'; +import { RegistroTrabajador } from './entities/trabajadore.entity'; +import { Repository } from 'typeorm'; +import * as fs from 'fs'; +import * as XLSX from 'xlsx'; +import { UsuarioData } from 'src/alumnos/alumnos.service'; @Injectable() export class TrabajadoresService { + constructor( + @InjectRepository(RegistroTrabajador, 'alumnosConnection') + private readonly trabajadorRepo: Repository, + ) {} + + async procesarArchivo( + filePath: string, + ): Promise<{ insertados: number; omitidos: number }> { + const workbook = XLSX.readFile(filePath); + const sheetName = workbook.SheetNames[0]; + const data: any[] = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]); + + let insertados = 0; + let omitidos = 0; + + for (const row of data) { + const rfc = (row['rfc'] || '').trim(); + + if (!rfc) { + omitidos++; + continue; // RFC es obligatorio + } + + const numTrabajadorRaw = row['num_empleado']; + const numTrabajador = Number.isFinite(Number(numTrabajadorRaw)) + ? Number(numTrabajadorRaw) + : undefined; + + const where: any[] = [{ rfc }]; + if (numTrabajador !== undefined) { + where.push({ num_trabajador: numTrabajador }); + } + + const existe = await this.trabajadorRepo.findOne({ where }); + + if (existe) { + omitidos++; + continue; + } + + const nuevo = this.trabajadorRepo.create({ + rfc, + num_trabajador: numTrabajador, + nombre: (row['Nombres'] || '').trim(), + apellidos: + `${(row['AP1'] || '').trim()} ${(row['AP2'] || '').trim()}`.trim(), + genero: (row['sexo'] || '').trim().substring(0, 1).toUpperCase(), + carrera: (row['adscripcion'] || '').trim(), + }); + + await this.trabajadorRepo.save(nuevo); + insertados++; + } + + fs.unlinkSync(filePath); // Limpia archivo temporal + + return { insertados, omitidos }; + } + + async findByRfc(rfc: string): Promise { + const trabajador = await this.trabajadorRepo.findOne({ + where: { rfc: rfc.trim().toUpperCase() }, + }); + + return { + cuenta: trabajador?.num_trabajador?.toString() || null, + nombre: trabajador?.nombre || null, + apellidos: trabajador + ? `${trabajador.apellidos}`.trim() + : null, + carrera: trabajador?.carrera || null, + genero: trabajador?.genero || null, + rfc: trabajador?.rfc || null, // Incluye RFC en el retorno + } + } + create(createTrabajadoreDto: CreateTrabajadoreDto) { return 'This action adds a new trabajadore'; } -- 2.43.0 From a64e4ea92567580be7355f553d93a75030002040 Mon Sep 17 00:00:00 2001 From: miguel Date: Fri, 20 Jun 2025 10:36:26 -0600 Subject: [PATCH 11/14] Update email subject to include event name for attendance validation --- src/cuestionario_respondido/cuestionario_respondido.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index ad16345..df52c7c 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -254,7 +254,7 @@ export class CuestionarioRespondidoService { await axios.post(`${urlApiCorreos}/mail/send`, { to: participante.correo, - subject: '🎓 Código QR para validar asistencia', + subject: `Evento: ${cuestionario.evento?.nombre_evento}`, fecha_recibido: '2025-03-10T12:00:00Z', html: generarHtmlCorreoAsistencia({ nombreForm: cuestionario.nombre_form, -- 2.43.0 From 8fe2b6cc72b190bd1e48be64c2edacb0df13bfd9 Mon Sep 17 00:00:00 2001 From: miguel Date: Fri, 20 Jun 2025 10:39:29 -0600 Subject: [PATCH 12/14] Disable schema synchronization and dropping for database configurations --- src/db/typeorm.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/db/typeorm.config.ts b/src/db/typeorm.config.ts index ba41ad2..8748b60 100644 --- a/src/db/typeorm.config.ts +++ b/src/db/typeorm.config.ts @@ -54,8 +54,8 @@ export const createMainDbConfig = async ( retryDelay: 3000, connectTimeout: 30000, - synchronize: true, - dropSchema: true, + synchronize: false, + dropSchema: false, }); export const createAlumnosDbConfig = async ( @@ -73,6 +73,6 @@ export const createAlumnosDbConfig = async ( retryDelay: 3000, connectTimeout: 30000, - synchronize: true, + synchronize: false, dropSchema: false, }); -- 2.43.0 From e20a381ba679e712bec49a9dfbd06deaec3a900d Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 23 Jun 2025 12:40:38 -0600 Subject: [PATCH 13/14] Add cupo_maximo field and related validations to cuestionario and evento modules --- src/cuestionario/cuestionario.controller.ts | 2 +- src/cuestionario/cuestionario.service.ts | 4 +- .../docs/cuestionario.examples.ts | 2 + .../dto/create-cuestionario.dto.ts | 9 +++ .../entities/cuestionario.entity.ts | 7 ++ .../cuestionario_respondido.service.ts | 23 ++++++- src/db/typeorm.config.ts | 6 +- src/evento/dto/outpus.dto.ts | 68 +++++++++++++++++++ src/evento/evento.service.ts | 56 +++++++++++++-- src/main.ts | 1 - src/tipo_cuestionario/dto/outputs.dto.ts | 9 +++ 11 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 src/evento/dto/outpus.dto.ts create mode 100644 src/tipo_cuestionario/dto/outputs.dto.ts diff --git a/src/cuestionario/cuestionario.controller.ts b/src/cuestionario/cuestionario.controller.ts index c7e5314..ad6c5b4 100644 --- a/src/cuestionario/cuestionario.controller.ts +++ b/src/cuestionario/cuestionario.controller.ts @@ -32,7 +32,7 @@ export class CuestionarioController { return this.cuestionarioService.create(createCuestionarioDto); } - @Post('evento') + @Post('withEvento') @CuestionarioApiDocumentation.ApiCreateWithEvento createCuestionarioEvento( @Body() createCuestionarioDto: CreateCuestionarioEventoDto, diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index 011fb4a..505ebac 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -66,6 +66,7 @@ export class CuestionarioService { createCuestionarioDto.secciones?.length || 0; cuestionario.editable = true; cuestionario.id_evento = createCuestionarioDto.id_evento; + cuestionario.cupo_maximo = createCuestionarioDto.cupo_maximo; const savedCuestionario = await queryRunner.manager.save(cuestionario); @@ -203,9 +204,6 @@ export class CuestionarioService { }; 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}`, diff --git a/src/cuestionario/docs/cuestionario.examples.ts b/src/cuestionario/docs/cuestionario.examples.ts index 0e4010c..3f43863 100644 --- a/src/cuestionario/docs/cuestionario.examples.ts +++ b/src/cuestionario/docs/cuestionario.examples.ts @@ -11,6 +11,7 @@ export const ejemploCuestionarioAlumno = { fecha_inicio: '2025-08-01T08:00:00', fecha_fin: '2025-08-10T23:59:59', id_tipo_cuestionario: 1, + cupo_maximo: 100, secciones: [ { titulo: 'Información Personal', @@ -88,6 +89,7 @@ export const ejemploCuestionarioTrabajador = { fecha_inicio: '2025-08-01T08:00:00', fecha_fin: '2025-08-10T23:59:59', id_tipo_cuestionario: 2, + cupo_maximo: 100, secciones: [ { titulo: 'Información Personal', diff --git a/src/cuestionario/dto/create-cuestionario.dto.ts b/src/cuestionario/dto/create-cuestionario.dto.ts index eb9c642..f90ee44 100644 --- a/src/cuestionario/dto/create-cuestionario.dto.ts +++ b/src/cuestionario/dto/create-cuestionario.dto.ts @@ -63,6 +63,15 @@ export class CreateCuestionarioDto { @IsNumber() id_tipo_cuestionario: number; + @ApiProperty({ + description: 'Cupo máximo de participantes', + example: 100, + required: false, + }) + @IsOptional() + @IsNumber() + cupo_maximo?: number; + @ApiProperty({ description: 'Nombre del evento asociado', example: 'Feria de la Sexualidad', diff --git a/src/cuestionario/entities/cuestionario.entity.ts b/src/cuestionario/entities/cuestionario.entity.ts index ee2f068..4521691 100644 --- a/src/cuestionario/entities/cuestionario.entity.ts +++ b/src/cuestionario/entities/cuestionario.entity.ts @@ -10,6 +10,7 @@ import { CuestionarioSeccion } from 'src/cuestionario_seccion/entities/cuestiona import { Evento } from 'src/evento/entities/evento.entity'; import { ParticipanteEvento } from 'src/participante_evento/entities/participante_evento.entity'; import { TipoCuestionario } from 'src/tipo_cuestionario/entities/tipo_cuestionario.entity'; +import { CuestionarioRespondido } from 'src/cuestionario_respondido/entities/cuestionario_respondido.entity'; @Entity('cuestionario') export class Cuestionario { @@ -37,6 +38,9 @@ export class Cuestionario { @Column({ type: 'datetime' }) fecha_fin: Date; + @Column({ type: 'int', nullable: true }) + cupo_maximo?: number; + @Column({ type: 'int' }) id_tipo_cuestionario: number; @@ -69,4 +73,7 @@ export class Cuestionario { (participanteEvento) => participanteEvento.cuestionario, ) inscripciones: ParticipanteEvento[]; + + @OneToMany(() => CuestionarioRespondido, (cr) => cr.cuestionario) + cuestionariosRespondidos: CuestionarioRespondido[]; } diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index df52c7c..ae9fe8e 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -56,6 +56,27 @@ export class CuestionarioRespondidoService { submitDto.id_cuestionario, ); + //1.1 Validar que no se haya rebasado el cupo máximo + if ( + cuestionario.cupo_maximo !== null && + cuestionario.cupo_maximo !== undefined + ) { + const totalRespondidos = + await this.cuestionarioRespondidoRepository.count({ + where: { + cuestionario: { id_cuestionario: submitDto.id_cuestionario }, + }, + }); + + if (totalRespondidos >= cuestionario.cupo_maximo) { + await queryRunner.rollbackTransaction(); + return { + success: false, + message: `El cupo máximo de ${cuestionario.cupo_maximo} participantes ha sido alcanzado para el cuestionario ${cuestionario.nombre_form}.`, + }; + } + } + // 2. Buscar o crear participante let participante = await this.participanteRepository.findOne({ where: { correo: submitDto.correo }, @@ -551,7 +572,7 @@ export class CuestionarioRespondidoService { return csv; } */ - async generarReporteRespuestas(idCuestionario: number): Promise { + async generarReporteRespuestas(idCuestionario: number): Promise { // Buscar el cuestionario para validar que existe const cuestionario = await this.cuestionarioRepository.findOne({ where: { id_cuestionario: idCuestionario }, diff --git a/src/db/typeorm.config.ts b/src/db/typeorm.config.ts index 8748b60..7164f7e 100644 --- a/src/db/typeorm.config.ts +++ b/src/db/typeorm.config.ts @@ -48,14 +48,14 @@ export const createMainDbConfig = async ( SeccionPregunta, TipoCuestionario, TipoPregunta, - TipoUser + TipoUser, ], retryAttempts: 5, retryDelay: 3000, connectTimeout: 30000, - synchronize: false, - dropSchema: false, + synchronize: configService.get('SYNCHRONIZE') || false, + dropSchema: configService.get('DROP_SCHEMA') || false, }); export const createAlumnosDbConfig = async ( diff --git a/src/evento/dto/outpus.dto.ts b/src/evento/dto/outpus.dto.ts new file mode 100644 index 0000000..de1e392 --- /dev/null +++ b/src/evento/dto/outpus.dto.ts @@ -0,0 +1,68 @@ +import { Expose, Type } from 'class-transformer'; +import { TipoCuestionarioOutputDto } from 'src/tipo_cuestionario/dto/outputs.dto'; + +export class CuestionarioConCupoDto { + @Expose() + id_cuestionario: number; + + @Expose() + nombre_form: string; + + @Expose() + descripcion?: string; + + @Expose() + banner: string; + + @Expose() + fecha_inicio: Date; + + @Expose() + fecha_fin: Date; + + @Expose() + cupo_maximo: number | null; + + @Expose() + cupos_usados: number; + + @Expose() + cupos_disponibles: number | null; + + @Expose() + id_evento: number; + + @Expose() + @Type(() => TipoCuestionarioOutputDto) + tipoCuestionario: TipoCuestionarioOutputDto; +} + +export class EventoConCuestionariosDto { + @Expose() + id_evento: number; + + @Expose() + tipo_evento: string; + + @Expose() + nombre_evento: string; + + @Expose() + descripcion_evento: string; + + @Expose() + fecha_inicio: Date; + + @Expose() + fecha_fin: Date; + + @Expose() + asistencias: any; + + @Expose() + banner: string | null; + + @Expose() + @Type(() => CuestionarioConCupoDto) // <- Esto es lo que faltaba + cuestionarios: CuestionarioConCupoDto[]; +} diff --git a/src/evento/evento.service.ts b/src/evento/evento.service.ts index a50ce57..e184d45 100644 --- a/src/evento/evento.service.ts +++ b/src/evento/evento.service.ts @@ -11,6 +11,8 @@ import { CreateEventoDto } from './dto/create-evento.dto'; import { UpdateEventoDto } from './dto/update.evento.dto'; import { join } from 'path'; import { existsSync, unlinkSync } from 'fs'; +import { plainToInstance } from 'class-transformer'; +import { EventoConCuestionariosDto } from './dto/outpus.dto'; @Injectable() export class EventoService { @@ -71,16 +73,36 @@ export class EventoService { } async getCuestionariosEvento(id_evento: number) { - const evento = await this.eventoRepository.findOne({ - where: { id_evento }, - relations: ['cuestionarios'], // solo los cuestionarios, sin secciones + const eventos = await this.eventoRepository.findOne({ + where: { + id_evento: id_evento, + }, + relations: ['cuestionarios', 'cuestionarios.cuestionariosRespondidos'], }); - if (!evento) { + if (!eventos) { throw new NotFoundException(`Evento con ID ${id_evento} no encontrado.`); } - return evento; + const eventoConCupos = { + ...eventos, + cuestionarios: eventos.cuestionarios.map((cuest) => { + const cupoMaximo = cuest.cupo_maximo ?? null; + const usados = cuest.cuestionariosRespondidos?.length || 0; + const disponibles = + cupoMaximo !== null ? Math.max(0, cupoMaximo - usados) : null; + + return { + ...cuest, + cupos_usados: usados, + cupos_disponibles: disponibles, + }; + }), + }; + + return plainToInstance(EventoConCuestionariosDto, eventoConCupos, { + excludeExtraneousValues: true, + }); } async getEvento(id_evento: number) { @@ -96,10 +118,30 @@ export class EventoService { where: { fecha_fin: MoreThan(new Date()), }, - relations: ['cuestionarios'], // solo los cuestionarios, sin secciones + relations: ['cuestionarios', 'cuestionarios.cuestionariosRespondidos'], }); - return eventos; + const eventosConCupos = eventos.map((evento) => { + return { + ...evento, + cuestionarios: evento.cuestionarios.map((cuest) => { + const cupoMaximo = cuest.cupo_maximo ?? null; + const usados = cuest.cuestionariosRespondidos?.length || 0; + const disponibles = + cupoMaximo !== null ? Math.max(0, cupoMaximo - usados) : null; + + return { + ...cuest, + cupos_usados: usados, + cupos_disponibles: disponibles, + }; + }), + }; + }); + + return plainToInstance(EventoConCuestionariosDto, eventosConCupos, { + excludeExtraneousValues: true, + }); } async getEventoOrFail(id_evento: number): Promise { diff --git a/src/main.ts b/src/main.ts index 528f3a9..b72cbda 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,7 +14,6 @@ async function bootstrap() { app.useGlobalPipes( new ValidationPipe({ whitelist: true, // Elimina propiedades no decoradas - forbidNonWhitelisted: true, // Arroja error si hay propiedades no decoradas transform: true, // Transforma los datos recibidos al tipo definido en el DTO }), ); diff --git a/src/tipo_cuestionario/dto/outputs.dto.ts b/src/tipo_cuestionario/dto/outputs.dto.ts new file mode 100644 index 0000000..1f225ee --- /dev/null +++ b/src/tipo_cuestionario/dto/outputs.dto.ts @@ -0,0 +1,9 @@ +import { Expose } from 'class-transformer'; + +export class TipoCuestionarioOutputDto { + @Expose() + id_tipo_cuestionario: number; + + @Expose() + tipo_cuestionario: string; +} \ No newline at end of file -- 2.43.0 From 551cc3e3b24a8d99322fc5f2c623f687bf2feb55 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 23 Jun 2025 18:58:20 -0600 Subject: [PATCH 14/14] Refactor cuestionario module to support event creation with associated questionnaire, including new DTO and enhanced error handling. --- src/cuestionario/cuestionario.controller.ts | 3 +- src/cuestionario/cuestionario.service.ts | 108 +++++++++--------- .../docs/cuestionario.documentation.ts | 48 +------- .../docs/cuestionario.examples.ts | 80 +++++++++++++ .../dto/create-evento-cuestionario.dto.ts | 7 ++ src/db/typeorm.config.ts | 4 +- 6 files changed, 146 insertions(+), 104 deletions(-) create mode 100644 src/cuestionario/dto/create-evento-cuestionario.dto.ts diff --git a/src/cuestionario/cuestionario.controller.ts b/src/cuestionario/cuestionario.controller.ts index ad6c5b4..2aaaa57 100644 --- a/src/cuestionario/cuestionario.controller.ts +++ b/src/cuestionario/cuestionario.controller.ts @@ -20,6 +20,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { diskStorage } from 'multer'; import { extname } from 'path'; import { CuestionarioApiDocumentation } from './docs/cuestionario.documentation'; +import { CreateEventoWithCuestionarioDto } from './dto/create-evento-cuestionario.dto'; @Controller('cuestionario') @CuestionarioApiDocumentation.ApiController @@ -35,7 +36,7 @@ export class CuestionarioController { @Post('withEvento') @CuestionarioApiDocumentation.ApiCreateWithEvento createCuestionarioEvento( - @Body() createCuestionarioDto: CreateCuestionarioEventoDto, + @Body() createCuestionarioDto: CreateEventoWithCuestionarioDto, ) { return this.cuestionarioService.createCuestionarioEvento( createCuestionarioDto, diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index 505ebac..c3a8aa1 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, In } from 'typeorm'; import { @@ -19,6 +23,7 @@ import { } from '../tipo_pregunta/entities/tipo_pregunta.entity'; import { Evento } from '../evento/entities/evento.entity'; import { EventoService } from '../evento/evento.service'; +import { CreateEventoWithCuestionarioDto } from './dto/create-evento-cuestionario.dto'; @Injectable() export class CuestionarioService { @@ -165,80 +170,72 @@ export class CuestionarioService { return { success: true, message: 'Cuestionario creado exitosamente', - cuestionario: savedCuestionario, + id_cuestionario: savedCuestionario.id_cuestionario, + id_evento: savedCuestionario.id_evento, }; } catch (error) { await queryRunner.rollbackTransaction(); - throw error; + throw new InternalServerErrorException( + 'Error al crear el cuestionario y evento', + error.message, + ); } finally { await queryRunner.release(); } } async createCuestionarioEvento( - createCuestionarioDto: CreateCuestionarioEventoDto, + createCuestionarioDto: CreateEventoWithCuestionarioDto, ) { + const { evento, cuestionario } = createCuestionarioDto; + const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { - // Verificar si se especificó un evento y búscarlo/crearlo - let idEvento: number | undefined = undefined; + // Buscar si ya existe un evento con el nombre proporcionado + let eventoExistente = await this.eventoRepository.findOne({ + where: { nombre_evento: evento.nombre_evento }, + }); - 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); - } else { - console.log( - `Evento encontrado: ${evento.nombre_evento}, ID: ${evento.id_evento}`, - ); - } - - idEvento = evento.id_evento; + // Si el evento no existe, crearlo + if (!eventoExistente) { + eventoExistente = queryRunner.manager.create(Evento, evento); + await queryRunner.manager.save(eventoExistente); + console.log(`Creando nuevo evento: ${evento.nombre_evento}`); } - // 1. Crear cuestionario - const cuestionario = new Cuestionario(); - cuestionario.nombre_form = createCuestionarioDto.nombre_form; - cuestionario.descripcion = createCuestionarioDto.descripcion; - cuestionario.fecha_inicio = new Date(createCuestionarioDto.fecha_inicio); - cuestionario.fecha_fin = new Date(createCuestionarioDto.fecha_fin); - cuestionario.id_tipo_cuestionario = - createCuestionarioDto.id_tipo_cuestionario; - cuestionario.contador_secciones = - createCuestionarioDto.secciones?.length || 0; - cuestionario.editable = true; + // Crear el cuestionario asociado al evento + const cuestionarioEntity = queryRunner.manager.create(Cuestionario, { + nombre_form: cuestionario.nombre_form, + descripcion: cuestionario.descripcion, + fecha_inicio: new Date(cuestionario.fecha_inicio), + fecha_fin: new Date(cuestionario.fecha_fin), + id_tipo_cuestionario: cuestionario.id_tipo_cuestionario, + contador_secciones: cuestionario.secciones?.length || 0, + editable: true, + id_evento: eventoExistente.id_evento, // Asociar el evento creado + cupo_maximo: cuestionario.cupo_maximo, + }); + const savedCuestionario = + await queryRunner.manager.save(cuestionarioEntity); - // Asociar el cuestionario con el evento si existe - if (idEvento !== undefined) { - cuestionario.id_evento = idEvento; - } + console.log( + `Cuestionario creado: ${savedCuestionario.nombre_form}, ID: ${savedCuestionario.id_cuestionario}`, + ); - const savedCuestionario = await queryRunner.manager.save(cuestionario); - - // 2. Crear secciones y vincularlas al cuestionario + // Crear las secciones del cuestionario if ( - createCuestionarioDto.secciones && - createCuestionarioDto.secciones.length > 0 + createCuestionarioDto.cuestionario.secciones && + createCuestionarioDto.cuestionario.secciones.length > 0 ) { - for (let i = 0; i < createCuestionarioDto.secciones.length; i++) { - const seccionDto = createCuestionarioDto.secciones[i]; + for ( + let i = 0; + i < createCuestionarioDto.cuestionario.secciones.length; + i++ + ) { + const seccionDto = createCuestionarioDto.cuestionario.secciones[i]; // Crear sección const seccion = new Seccion(); @@ -335,10 +332,11 @@ export class CuestionarioService { } await queryRunner.commitTransaction(); - return { success: true, - cuestionario: savedCuestionario, + message: 'Cuestionario y evento creados exitosamente', + id_cuestionario: savedCuestionario.id_cuestionario, + id_evento: eventoExistente.id_evento, }; } catch (error) { await queryRunner.rollbackTransaction(); diff --git a/src/cuestionario/docs/cuestionario.documentation.ts b/src/cuestionario/docs/cuestionario.documentation.ts index b4a8550..6d1b45a 100644 --- a/src/cuestionario/docs/cuestionario.documentation.ts +++ b/src/cuestionario/docs/cuestionario.documentation.ts @@ -13,7 +13,7 @@ import { CreateCuestionarioDto, CreateCuestionarioEventoDto, } from '../dto/create-cuestionario.dto'; -import { ejemploCuestionarioAlumno } from './cuestionario.examples'; +import { ejemploCreateEventoWithCuestionario, ejemploCuestionarioAlumno } from './cuestionario.examples'; export class CuestionarioApiDocumentation { // Decoradores para toda la clase del controlador @@ -47,18 +47,6 @@ export class CuestionarioApiDocumentation { type: 'number', example: 1, }), -/* ApiResponse({ - status: 200, - 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': { - schema: { $ref: getSchemaPath(FormularioDto) }, - example: feriaSexualidad, - }, - }, - }), */ ApiResponse({ status: 404, description: 'Cuestionario no encontrado' }), ); @@ -79,39 +67,7 @@ export class CuestionarioApiDocumentation { ApiBody({ type: CreateCuestionarioEventoDto, examples: { - ejemplo_con_evento_nuevo: { - summary: 'Cuestionario que crea evento automáticamente', - value: { - nombre_form: 'Registro de participantes', - descripcion: 'Formulario para inscripción de asistentes.', - evento: 'Foro Juvenil de Tecnología 2025', - fecha_inicio: '2025-07-20T10:00:00', - fecha_fin: '2025-07-20T17:00:00', - id_tipo_cuestionario: 2, - secciones: [ - { - titulo: 'Datos generales', - descripcion: 'Recopilación de información personal', - preguntas: [ - { - titulo: '¿Cuál es tu edad?', - tipo: 'Abierta', - obligatoria: true, - }, - { - titulo: '¿Cómo te enteraste del evento?', - tipo: 'Multiple', - opciones: [ - { valor: 'Redes sociales' }, - { valor: 'Correo electrónico' }, - { valor: 'Por un amigo' }, - ], - }, - ], - }, - ], - }, - }, + ejemplo_con_evento_nuevo: ejemploCreateEventoWithCuestionario, }, }), ); diff --git a/src/cuestionario/docs/cuestionario.examples.ts b/src/cuestionario/docs/cuestionario.examples.ts index 3f43863..1f06a18 100644 --- a/src/cuestionario/docs/cuestionario.examples.ts +++ b/src/cuestionario/docs/cuestionario.examples.ts @@ -1,5 +1,85 @@ +import { ejemploFeriaSexualidad } from 'src/evento/docs/evento.examples'; import { TiposValidacion } from 'src/pregunta/entities/pregunta.entity'; +export const ejemploCreateEventoWithCuestionario = { + summary: 'Crear un evento con cuestionario asociado', + value: { + evento: ejemploFeriaSexualidad.value, + cuestionario: { + nombre_form: 'Registro', + descripcion: + 'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.', + fecha_inicio: '2025-08-01T08:00:00', + fecha_fin: '2025-08-10T23:59:59', + id_tipo_cuestionario: 1, + cupo_maximo: 100, + secciones: [ + { + titulo: 'Información Personal', + descripcion: + 'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.', + preguntas: [ + { + titulo: '¿Eres parte de la comunidad de la FES Acatlán?', + tipo: 'Cerrada', + opciones: [{ valor: 'Si' }, { valor: 'No' }], + obligatoria: true, + validacion: TiposValidacion.COMUNIDAD_ALUMNO, + }, + { + titulo: 'Numero de cuenta', + tipo: 'Abierta (Respuesta corta)', + obligatoria: false, + validacion: TiposValidacion.CUENTA_ALUMNO, + }, + { + titulo: 'Correo electrónico', + tipo: 'Abierta (Respuesta corta)', + obligatoria: true, + validacion: TiposValidacion.CORREO, + }, + { + titulo: 'Nombre(s)', + tipo: 'Abierta (Respuesta corta)', + obligatoria: true, + validacion: TiposValidacion.NOMBRE, + }, + { + titulo: 'Apellidos', + tipo: 'Abierta (Respuesta corta)', + obligatoria: true, + validacion: TiposValidacion.APELLIDOS, + }, + { + titulo: 'Género', + tipo: 'Cerrada', + obligatoria: true, + validacion: TiposValidacion.GENERO, + opciones: [ + { valor: 'Masculino' }, + { valor: 'Femenino' }, + { valor: 'Prefiero no decirlo' }, + ], + }, + { + titulo: 'Institución de procedencia', + tipo: 'Abierta (Respuesta corta)', + obligatoria: true, + validacion: TiposValidacion.INSTITUCION, + }, + { + titulo: 'Carrera', + tipo: 'Abierta (Respuesta corta)', + obligatoria: true, + validacion: TiposValidacion.CARRERA, + }, + ], + }, + ], + }, + }, +}; + export const ejemploCuestionarioAlumno = { summary: 'Cuestionario para el registro de asistencia a un evento para comunidad estudiantil', diff --git a/src/cuestionario/dto/create-evento-cuestionario.dto.ts b/src/cuestionario/dto/create-evento-cuestionario.dto.ts new file mode 100644 index 0000000..2985fcf --- /dev/null +++ b/src/cuestionario/dto/create-evento-cuestionario.dto.ts @@ -0,0 +1,7 @@ +import { CreateEventoDto } from 'src/evento/dto/create-evento.dto'; +import { CreateCuestionarioDto, CreateCuestionarioEventoDto } from './create-cuestionario.dto'; + +export interface CreateEventoWithCuestionarioDto { + evento: CreateEventoDto; + cuestionario: CreateCuestionarioEventoDto; +} diff --git a/src/db/typeorm.config.ts b/src/db/typeorm.config.ts index 7164f7e..1ae25ac 100644 --- a/src/db/typeorm.config.ts +++ b/src/db/typeorm.config.ts @@ -54,8 +54,8 @@ export const createMainDbConfig = async ( retryDelay: 3000, connectTimeout: 30000, - synchronize: configService.get('SYNCHRONIZE') || false, - dropSchema: configService.get('DROP_SCHEMA') || false, + synchronize: configService.get('SYNCHRONIZE') === 'true', + dropSchema: configService.get('DROP_SCHEMA') === 'true', }); export const createAlumnosDbConfig = async ( -- 2.43.0