From d652f9f0129aa06495716b044244cbdd0c7501d0 Mon Sep 17 00:00:00 2001 From: jorgemike Date: Fri, 13 Jun 2025 22:41:20 -0600 Subject: [PATCH 01/62] 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', From 63e1d3183eab095ad6effdc2d1313a8ea13a751a Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 16 Jun 2025 12:23:49 -0600 Subject: [PATCH 02/62] 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'; } From 5f302df2a7adf6fcd88c81aded92acbd3215c3ed Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 16 Jun 2025 13:49:12 -0600 Subject: [PATCH 03/62] 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 From d188d8bea32c51c529564458d20e37530474b45c Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 16 Jun 2025 18:24:56 -0600 Subject: [PATCH 04/62] 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 From 7d8c9e9ef49f32d4a86543abfdd5086c1fb4042c Mon Sep 17 00:00:00 2001 From: miguel Date: Wed, 18 Jun 2025 10:59:06 -0600 Subject: [PATCH 05/62] 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`; + } +} From 6c85847bf45cc55bdd0f7142b5ed11e45513fd4b Mon Sep 17 00:00:00 2001 From: miguel Date: Wed, 18 Jun 2025 11:00:44 -0600 Subject: [PATCH 06/62] 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, }); From 282f921e4362d52a7b8a55b7d66834a4de98a661 Mon Sep 17 00:00:00 2001 From: miguel Date: Wed, 18 Jun 2025 12:41:30 -0600 Subject: [PATCH 07/62] 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 From 3d90c51742649dba0b5aa9f3135e982e6f4652ad Mon Sep 17 00:00:00 2001 From: miguel Date: Wed, 18 Jun 2025 12:57:52 -0600 Subject: [PATCH 08/62] 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, }); From ae0313cd1f1bde82e1367684a782f6712522c26e Mon Sep 17 00:00:00 2001 From: miguel Date: Wed, 18 Jun 2025 15:52:37 -0600 Subject: [PATCH 09/62] 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 ( From 74de90eda86ae1fb62eeb518853443a9f6579476 Mon Sep 17 00:00:00 2001 From: miguel Date: Thu, 19 Jun 2025 18:36:29 -0600 Subject: [PATCH 10/62] 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'; } From a64e4ea92567580be7355f553d93a75030002040 Mon Sep 17 00:00:00 2001 From: miguel Date: Fri, 20 Jun 2025 10:36:26 -0600 Subject: [PATCH 11/62] 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, From 8fe2b6cc72b190bd1e48be64c2edacb0df13bfd9 Mon Sep 17 00:00:00 2001 From: miguel Date: Fri, 20 Jun 2025 10:39:29 -0600 Subject: [PATCH 12/62] 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, }); From e20a381ba679e712bec49a9dfbd06deaec3a900d Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 23 Jun 2025 12:40:38 -0600 Subject: [PATCH 13/62] 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 From 551cc3e3b24a8d99322fc5f2c623f687bf2feb55 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 23 Jun 2025 18:58:20 -0600 Subject: [PATCH 14/62] 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 ( From 1732065ced0c28fa46959e9b6cc447b157471521 Mon Sep 17 00:00:00 2001 From: jorgemike Date: Sat, 28 Jun 2025 19:07:08 -0600 Subject: [PATCH 15/62] token correos --- post.json | 10 ++++ .../cuestionario_respondido.service.ts | 56 +++++++++++-------- 2 files changed, 42 insertions(+), 24 deletions(-) create mode 100644 post.json diff --git a/post.json b/post.json new file mode 100644 index 0000000..bd2aeba --- /dev/null +++ b/post.json @@ -0,0 +1,10 @@ +{ + "Nombre": "Sistema de Registro Eventos", + "ip": "192.168.0.10", + "email": "registro-eventos@acatlan.unam.mx", + "email_password": "c4Gl-06k9%h", + "password": "E5cCF5b-92d", + "clientId": "963087784448-i4pg060bkumkhk75r4po5esaglij6dq2.apps.googleusercontent.com", + "refreshToken": "1//04-ba9LCW4EQ9CgYIARAAGAQSNwF-L9IrOlLXH2Piku5W37ujhF4N3sBMMJqTAvXhjzKiYy7ckuOoA6sIcBZpu-d-E7-no286kzU", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzaXN0ZW1hSWQiOjIsIm5vbWJyZSI6IlNpc3RlbWEgZGUgUmVnaXN0cm8gRXZlbnRvcyIsImlwIjoiMTkyLjE2OC4wLjEwIiwiaWF0IjoxNzUxMTU1OTU0LCJleHAiOjE3NTEyNDIzNTR9.9JzBnjlkw9SqTAlpN2UfsWwLS7HzeIyosfRCNA3Lj0Y%" +} diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index ae9fe8e..ab64aee 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -273,31 +273,39 @@ export class CuestionarioRespondidoService { ); } - await axios.post(`${urlApiCorreos}/mail/send`, { - to: participante.correo, - subject: `Evento: ${cuestionario.evento?.nombre_evento}`, - 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(), - 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: [ - { - filename: 'qr.png', - content: qrBuffer.toString('base64'), - encoding: 'base64', - cid: 'qrCode', + await axios.post( + `${urlApiCorreos}/mail/send`, + { + to: participante.correo, + subject: `Evento: ${cuestionario.evento?.nombre_evento}`, + 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(), + 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: [ + { + filename: 'qr.png', + content: qrBuffer.toString('base64'), + encoding: 'base64', + cid: 'qrCode', + }, + ], + }, + { + headers: { + token: process.env.SYSTEM_TOKEN_API_CORREOS, }, - ], - }); + }, + ); console.log(`Correo enviado exitosamente a ${participante.correo}`); } catch (error) { From 7c06ed0b2c13048cb339d1cb9c9b6678dc7a7c78 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 30 Jun 2025 11:10:40 -0600 Subject: [PATCH 16/62] Update attendance validation message in event registration email --- src/emails/registro-evento.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/emails/registro-evento.ts b/src/emails/registro-evento.ts index c533d66..7a544ad 100644 --- a/src/emails/registro-evento.ts +++ b/src/emails/registro-evento.ts @@ -33,7 +33,7 @@ export function generarHtmlCorreoAsistencia({

    - Presenta este código QR (en tu celular o impreso) en cualquier stand para validar tu asistencia y recibir tu constancia. + Favor de presentar este código QR (celular o impreso) en la mesa de registro. el día del evento, para validar su asistencia y tener derecho a la constancia.

    From 641514b8f8d87416e7d6d110e922a25ff77a17cd Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 11 Aug 2025 09:46:46 -0600 Subject: [PATCH 17/62] Add 'recientes' endpoint and implement logic to fetch recent events from the service --- src/evento/evento.controller.ts | 5 +++++ src/evento/evento.service.ts | 26 ++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/evento/evento.controller.ts b/src/evento/evento.controller.ts index f202559..a3bb108 100644 --- a/src/evento/evento.controller.ts +++ b/src/evento/evento.controller.ts @@ -41,6 +41,11 @@ export class EventoController { return this.eventoService.getEventosActivosCuestionarios(); } + @Get('recientes') + getEventosRecientes(): Promise { + return this.eventoService.getEventosRecientes(); + } + @Get('activos') getEventosActivos(): Promise { return this.eventoService.getEventosActivos(); diff --git a/src/evento/evento.service.ts b/src/evento/evento.service.ts index e184d45..801b76b 100644 --- a/src/evento/evento.service.ts +++ b/src/evento/evento.service.ts @@ -57,8 +57,14 @@ export class EventoService { if (existsSync(bannerPath)) { try { unlinkSync(bannerPath); - } catch (err) { - console.error(`Error al eliminar el banner anterior: ${err.message}`); + } catch (err: unknown) { + let errorMessage = ''; + if (err && typeof err === 'object' && 'message' in err) { + errorMessage = (err as { message?: string }).message ?? ''; + } + console.error( + `Error al eliminar el banner anterior: ${errorMessage}`, + ); } } } @@ -180,6 +186,22 @@ export class EventoService { return this.eventoRepository.save(updateEvento); } + async getEventosRecientes(): Promise { + const now = new Date(); + const pastDate = new Date(); + pastDate.setDate(now.getDate() - 30); // Últimos 30 días + + return this.eventoRepository.find({ + where: { + fecha_inicio: MoreThan(pastDate), + }, + order: { + fecha_inicio: 'DESC', + }, + take: 10, // Limitar a los 10 eventos más recientes + }); + } + async getEventosActivos() { const now = new Date(); return this.eventoRepository.find({ From 0a80d892b31eebe14da84a167ae1c31bfa591035 Mon Sep 17 00:00:00 2001 From: miguel Date: Mon, 11 Aug 2025 10:22:11 -0600 Subject: [PATCH 18/62] Add 'recientes' endpoints for cuestionarios and eventos with enhanced data retrieval --- src/cuestionario/cuestionario.controller.ts | 11 ++++-- src/cuestionario/cuestionario.service.ts | 16 +++++++- src/evento/evento.controller.ts | 6 +-- src/evento/evento.service.ts | 41 ++++++++++++++++----- 4 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/cuestionario/cuestionario.controller.ts b/src/cuestionario/cuestionario.controller.ts index 2aaaa57..a4352b4 100644 --- a/src/cuestionario/cuestionario.controller.ts +++ b/src/cuestionario/cuestionario.controller.ts @@ -11,10 +11,7 @@ import { UploadedFile, } from '@nestjs/common'; import { CuestionarioService } from './cuestionario.service'; -import { - CreateCuestionarioDto, - CreateCuestionarioEventoDto, -} from './dto/create-cuestionario.dto'; +import { CreateCuestionarioDto } from './dto/create-cuestionario.dto'; import { UpdateCuestionarioDto } from './dto/update-cuestionario.dto'; import { FileInterceptor } from '@nestjs/platform-express'; import { diskStorage } from 'multer'; @@ -83,6 +80,12 @@ export class CuestionarioController { return this.cuestionarioService.findAll(); } + @Get('recientes') + @CuestionarioApiDocumentation.ApiGetAll + findAllRecientes() { + return this.cuestionarioService.findAllRecientes(); + } + @Get(':id') @CuestionarioApiDocumentation.ApiGetOne findOne(@Param('id') id: string) { diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index c3a8aa1..f4134c4 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -4,7 +4,7 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DataSource, In } from 'typeorm'; +import { Repository, DataSource, In, MoreThan } from 'typeorm'; import { CreateCuestionarioDto, CreateCuestionarioEventoDto, @@ -184,6 +184,20 @@ export class CuestionarioService { } } + async findAllRecientes() { + console.log('Buscando cuestionarios recientes'); + const now = new Date(); + const pastDate = new Date(); + pastDate.setDate(now.getDate() - 30); + + return this.cuestionarioRepository.find({ + where: { + fecha_inicio: MoreThan(pastDate), + }, + order: { fecha_fin: 'DESC' }, + }); + } + async createCuestionarioEvento( createCuestionarioDto: CreateEventoWithCuestionarioDto, ) { diff --git a/src/evento/evento.controller.ts b/src/evento/evento.controller.ts index a3bb108..c84c896 100644 --- a/src/evento/evento.controller.ts +++ b/src/evento/evento.controller.ts @@ -41,9 +41,9 @@ export class EventoController { return this.eventoService.getEventosActivosCuestionarios(); } - @Get('recientes') - getEventosRecientes(): Promise { - return this.eventoService.getEventosRecientes(); + @Get('recientes/cuestionarios') + getEventosRecientes() { + return this.eventoService.getEventosRecientesCuestionarios(); } @Get('activos') diff --git a/src/evento/evento.service.ts b/src/evento/evento.service.ts index 801b76b..9b86f9b 100644 --- a/src/evento/evento.service.ts +++ b/src/evento/evento.service.ts @@ -186,19 +186,40 @@ export class EventoService { return this.eventoRepository.save(updateEvento); } - async getEventosRecientes(): Promise { - const now = new Date(); - const pastDate = new Date(); - pastDate.setDate(now.getDate() - 30); // Últimos 30 días + async getEventosRecientesCuestionarios(): Promise< + EventoConCuestionariosDto[] + > { + const ahora = new Date(); + const hace30Dias = new Date(); + hace30Dias.setDate(ahora.getDate() - 30); - return this.eventoRepository.find({ + const eventos = await this.eventoRepository.find({ where: { - fecha_inicio: MoreThan(pastDate), + fecha_fin: MoreThan(hace30Dias), }, - order: { - fecha_inicio: 'DESC', - }, - take: 10, // Limitar a los 10 eventos más recientes + relations: ['cuestionarios', 'cuestionarios.cuestionariosRespondidos'], + }); + + 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, }); } From 94002a3e5046e06c124e67eb0739d3b714e48aea Mon Sep 17 00:00:00 2001 From: miguel Date: Tue, 12 Aug 2025 20:00:18 -0600 Subject: [PATCH 19/62] Enhance email functionality by adding QR code generation and resend logic for participants who have already responded to the questionnaire. Update email template for event ticket confirmation with detailed registration information. --- .vscode/settings.json | 3 + .../cuestionario_respondido.service.ts | 75 ++++- src/emails/registro-evento-ticket.ts | 307 ++++++++++++++++++ src/evento/entities/evento.entity.ts | 7 +- src/evento/evento.service.ts | 10 +- 5 files changed, 393 insertions(+), 9 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/emails/registro-evento-ticket.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..65a1965 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode" +} diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index ab64aee..fdc1a1e 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -101,9 +101,80 @@ export class CuestionarioRespondidoService { ); if (cuestionarioRespondidoExistente) { await queryRunner.rollbackTransaction(); + + // Aunque ya haya respondido, enviamos el correo nuevamente + if (cuestionario && cuestionario.evento) { + try { + // Generar datos para el QR + const datosQR = { + id_participante: participante.id_participante, + id_evento: cuestionario.evento.id_evento, + id_cuestionario: cuestionario.id_cuestionario, + }; + + // Generar código QR + const qrJsonData = JSON.stringify(datosQR); + const qrBuffer = await QRCode.toBuffer(qrJsonData); + + // Enviar correo con el QR + const urlApiCorreos = process.env.url_api_correos; + + if (urlApiCorreos) { + await axios.post( + `${urlApiCorreos}/mail/send`, + { + to: participante.correo, + subject: `Evento: ${cuestionario.evento?.nombre_evento}`, + fecha_recibido: new Date().toISOString(), + html: generarHtmlCorreoAsistencia({ + nombreForm: cuestionario.nombre_form, + 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: [ + { + filename: 'qr.png', + content: qrBuffer.toString('base64'), + encoding: 'base64', + cid: 'qrCode', + }, + ], + }, + { + headers: { + token: process.env.SYSTEM_TOKEN_API_CORREOS, + }, + }, + ); + + console.log( + `Correo reenviado exitosamente a ${participante.correo}`, + ); + } + } catch (error) { + console.error('Error al reenviar correo:', error); + } + } + return { - success: false, - message: `El participante con correo ${submitDto.correo} ya ha respondido el cuestionario ${cuestionario.nombre_form}.`, + success: true, + message: `El participante con correo ${submitDto.correo} ya había respondido el cuestionario ${cuestionario.nombre_form}. Se ha reenviado el correo con el código QR.`, + datos_qr: cuestionario.evento + ? { + id_participante: participante.id_participante, + id_evento: cuestionario.evento.id_evento, + id_cuestionario: cuestionario.id_cuestionario, + } + : undefined, }; } diff --git a/src/emails/registro-evento-ticket.ts b/src/emails/registro-evento-ticket.ts new file mode 100644 index 0000000..19a5814 --- /dev/null +++ b/src/emails/registro-evento-ticket.ts @@ -0,0 +1,307 @@ +export function generarHtmlCorreoAsistenciaTicket({ + nombreForm, + nombreEvento, + correo, + fechaRegistro, + fechaInicioEvento, + fechaFinEvento, +}: { + nombreForm: string; + nombreEvento: string; + correo: string; + fechaRegistro: string; + fechaInicioEvento?: string; + fechaFinEvento?: string; +}) { + const horarioEvento = + fechaInicioEvento && fechaFinEvento + ? `${fechaInicioEvento} al ${fechaFinEvento}` + : 'Por confirmar'; + + return ` +
    + +
    +

    ¡Registro Confirmado!

    +
    + + +
    + +
    +

    + 🎓 ${nombreEvento || 'Evento Especial'} +

    +

    + TICKET DE ENTRADA • ENTRADA GRATUITA +

    +
    + + +
    +
    + +
    +
    + Código QR de Entrada +

    + ESCANEA PARA REGISTRAR ASISTENCIA +

    +
    +
    + + +
    +
    +

    + 📋 Detalles de tu registro +

    + +
    +
    +

    + Participante +

    +

    + ${correo} +

    +
    + +
    +

    + Formulario +

    +

    + ${nombreForm} +

    +
    + +
    +

    + Fecha de Registro +

    +

    + ${fechaRegistro} +

    +
    + +
    +

    + 🕒 Horario del Evento +

    +

    + ${horarioEvento} +

    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +

    + IMPORTANTE: Presenta este código QR en la mesa + de registro +

    +

    + Disponible en formato digital o impreso +

    +
    +
    +

    + ENTRADA VÁLIDA +

    +

    + ✓ Verificado +

    +
    +
    +
    +
    + + +
    +

    + 📝 Instrucciones importantes: +

    +
      +
    • Llega 15 minutos antes del inicio del evento
    • +
    • + Presenta este QR en la mesa de registro para validar tu asistencia +
    • +
    • Tu constancia será otorgada al finalizar el evento
    • +
    • Para dudas, 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/entities/evento.entity.ts b/src/evento/entities/evento.entity.ts index a86530e..d297f7e 100644 --- a/src/evento/entities/evento.entity.ts +++ b/src/evento/entities/evento.entity.ts @@ -1,10 +1,5 @@ import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity'; -import { - Column, - Entity, - OneToMany, - PrimaryGeneratedColumn, -} from 'typeorm'; +import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class Evento { diff --git a/src/evento/evento.service.ts b/src/evento/evento.service.ts index 9b86f9b..b7edb73 100644 --- a/src/evento/evento.service.ts +++ b/src/evento/evento.service.ts @@ -225,11 +225,19 @@ export class EventoService { async getEventosActivos() { const now = new Date(); - return this.eventoRepository.find({ + const eventos = await this.eventoRepository.find({ where: { fecha_fin: MoreThan(now), }, + relations: ['cuestionarios'], }); + + return eventos.map((evento) => ({ + ...evento, + total_cuestionarios: evento.cuestionarios + ? evento.cuestionarios.length + : 0, + })); } async getCuestionarioEvento(id_evento: number, id_cuestionario: number) { From bc5ddb29c76554fcfd41888de368f20dc9fd2ba1 Mon Sep 17 00:00:00 2001 From: miguel Date: Tue, 19 Aug 2025 10:27:06 -0600 Subject: [PATCH 20/62] feat: add TipoEvento entity and related functionality - Introduced TipoEvento entity with enum for event types. - Created DTOs for creating and updating TipoEvento. - Implemented TipoEvento service with methods for CRUD operations and seeding initial data. - Added TipoEvento controller for handling HTTP requests related to event types. - Integrated TipoEvento into the main application module. - Updated Evento entity to include a relationship with TipoEvento. - Enhanced ParticipanteEvento service to register attendance using QR tokens. - Implemented QR token generation and validation for event attendance. - Updated API documentation for new endpoints and functionalities. - Adjusted TypeOrm configuration to include new entities. --- eslint.config.mjs | 59 +++++------ init-scripts/init-tipo-eventos.sql | 14 +++ src/administrador/administrador.controller.ts | 32 ++++-- src/administrador/administrador.service.ts | 50 +++++----- .../{ => docs}/administrador.documentation.ts | 9 +- .../dto/create-administrador.dto.ts | 39 +++++--- .../dto/login-administrador.dto.ts | 23 +++-- .../entities/administrador.entity.ts | 18 +++- src/app.module.ts | 2 + src/auth/decorators/roles.decorator.ts | 5 + src/auth/guards/jwt-validation.guard.ts | 52 ++++++++++ src/auth/guards/roles.guard.ts | 46 +++++++++ src/auth/jwt.strategy.ts | 13 ++- src/cuestionario/cuestionario.service.ts | 2 + .../dto/create-cuestionario.dto.ts | 8 ++ .../entities/cuestionario.entity.ts | 14 ++- .../cuestionario_respondido.module.ts | 2 + .../cuestionario_respondido.service.ts | 50 ++++++---- src/db/typeorm.config.ts | 4 +- src/evento/dto/outpus.dto.ts | 5 + src/evento/entities/evento.entity.ts | 16 ++- .../dto/registrar-asistencia-qr.dto.ts | 12 +++ .../participante_evento.controller.ts | 45 ++++++++- .../participante_evento.module.ts | 2 + .../participante_evento.service.ts | 81 +++++++++++++++ src/qr/dto/validate-qr-token.dto.ts | 12 +++ src/qr/qr-token.service.ts | 89 +++++++++++++++++ src/qr/qr.controller.ts | 62 +++++++++++- src/qr/qr.module.ts | 8 +- src/tipo_cuestionario/dto/outputs.dto.ts | 10 +- src/tipo_evento/dto/create-tipo_evento.dto.ts | 8 ++ src/tipo_evento/dto/outputs.dto.ts | 9 ++ src/tipo_evento/dto/update-tipo_evento.dto.ts | 4 + .../entities/tipo_evento.entity.ts | 34 +++++++ src/tipo_evento/tipo_evento.controller.ts | 46 +++++++++ src/tipo_evento/tipo_evento.module.ts | 13 +++ src/tipo_evento/tipo_evento.service.ts | 98 +++++++++++++++++++ src/tipo_user/entities/tipo_user.entity.ts | 5 + src/tipo_user/tipo_user.service.ts | 21 +++- 39 files changed, 882 insertions(+), 140 deletions(-) create mode 100644 init-scripts/init-tipo-eventos.sql rename src/administrador/{ => docs}/administrador.documentation.ts (97%) create mode 100644 src/auth/decorators/roles.decorator.ts create mode 100644 src/auth/guards/jwt-validation.guard.ts create mode 100644 src/auth/guards/roles.guard.ts create mode 100644 src/participante_evento/dto/registrar-asistencia-qr.dto.ts create mode 100644 src/qr/dto/validate-qr-token.dto.ts create mode 100644 src/qr/qr-token.service.ts create mode 100644 src/tipo_evento/dto/create-tipo_evento.dto.ts create mode 100644 src/tipo_evento/dto/outputs.dto.ts create mode 100644 src/tipo_evento/dto/update-tipo_evento.dto.ts create mode 100644 src/tipo_evento/entities/tipo_evento.entity.ts create mode 100644 src/tipo_evento/tipo_evento.controller.ts create mode 100644 src/tipo_evento/tipo_evento.module.ts create mode 100644 src/tipo_evento/tipo_evento.service.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 32465cc..47f45d6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,35 +1,24 @@ -// @ts-check -import eslint from '@eslint/js'; -import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; -import globals from 'globals'; -import tseslint from 'typescript-eslint'; - -export default tseslint.config( - { - ignores: ['eslint.config.mjs'], - }, - eslint.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, - eslintPluginPrettierRecommended, - { - languageOptions: { - globals: { - ...globals.node, - ...globals.jest, - }, - ecmaVersion: 5, - sourceType: 'module', - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, - }, - }, - { - rules: { - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-floating-promises': 'warn', - '@typescript-eslint/no-unsafe-argument': 'warn' - }, - }, -); \ No newline at end of file +module.exports = { + // parser: '@typescript-eslint/parser', + // parserOptions: { + // project: 'tsconfig.json', + // sourceType: 'module', + // }, + // plugins: ['@typescript-eslint/eslint-plugin'], + // extends: [ + // 'plugin:@typescript-eslint/recommended', + // 'plugin:prettier/recommended', + // ], + // root: true, + // env: { + // node: true, + // jest: true, + // }, + // ignorePatterns: ['.eslintrc.js'], + // rules: { + // '@typescript-eslint/interface-name-prefix': 'off', + // '@typescript-eslint/explicit-function-return-type': 'off', + // '@typescript-eslint/explicit-module-boundary-types': 'off', + // '@typescript-eslint/no-explicit-any': 'off', + // }, +}; diff --git a/init-scripts/init-tipo-eventos.sql b/init-scripts/init-tipo-eventos.sql new file mode 100644 index 0000000..32e903d --- /dev/null +++ b/init-scripts/init-tipo-eventos.sql @@ -0,0 +1,14 @@ +INSERT INTO tipo_evento (tipo_evento) VALUES +('Académico'), +('Taller / Capacitación'), +('Conferencia / Charla'), +('Panel / Mesa redonda'), +('Feria / Expo'), +('Networking / Vinculación'), +('Entrevista / Sesión 1:1'), +('Cultural / Artístico'), +('Deportivo / Recreativo'), +('Institucional / Ceremonial'), +('Social / Comunitario'), +('Aniversario'), +('Otro'); diff --git a/src/administrador/administrador.controller.ts b/src/administrador/administrador.controller.ts index bffcfe1..7c25e0d 100644 --- a/src/administrador/administrador.controller.ts +++ b/src/administrador/administrador.controller.ts @@ -15,9 +15,12 @@ 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, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { AdministradorApiDocumentation } from './administrador.documentation'; +import { AdministradorApiDocumentation } from './docs/administrador.documentation'; +import { JwtValidationGuard } from 'src/auth/guards/jwt-validation.guard'; +import { Roles } from 'src/auth/decorators/roles.decorator'; +import { RolesGuard } from 'src/auth/guards/roles.guard'; @AdministradorApiDocumentation.ApiController @ApiBearerAuth() @@ -26,20 +29,25 @@ export class AdministradorController { constructor(private administradorService: AdministradorService) {} @Get() - @UseGuards(JwtAuthGuard) + @UseGuards(RolesGuard) + @UseGuards(JwtValidationGuard) + @Roles('administrador') @AdministradorApiDocumentation.ApiGetAll getAdministradores(): Promise { return this.administradorService.getAdministradores(); } @Get(':id') - @UseGuards(JwtAuthGuard) + @UseGuards(RolesGuard) + @UseGuards(JwtValidationGuard) + @Roles('administrador') @AdministradorApiDocumentation.ApiGetOne getAdministrador(@Param('id', ParseIntPipe) id: number) { - return this.administradorService.getAdministrador(id); + return this.administradorService.getAdminByIdOrFail(id); } @Post() + @Roles('administrador') @AdministradorApiDocumentation.ApiCreate createAdministrador(@Body() newAdministrador: CreateAdministradorDto) { return this.administradorService.createAdministrador(newAdministrador); @@ -49,13 +57,15 @@ export class AdministradorController { @AdministradorApiDocumentation.ApiLogin login(@Body() loginData: LoginAdministradorDto) { return this.administradorService.login( - loginData.correo, + loginData.nombre_usuario, loginData.password, ); } @Patch(':id/change-password') - @UseGuards(JwtAuthGuard) + @UseGuards(RolesGuard) + @UseGuards(JwtValidationGuard) + @Roles('administrador') @AdministradorApiDocumentation.ApiChangePassword changePassword( @Param('id', ParseIntPipe) id: number, @@ -69,14 +79,18 @@ export class AdministradorController { } @Delete(':id') - @UseGuards(JwtAuthGuard) + @UseGuards(RolesGuard) + @UseGuards(JwtValidationGuard) + @Roles('administrador') @AdministradorApiDocumentation.ApiRemove deleteAdministrador(@Param('id', ParseIntPipe) id: number) { return this.administradorService.deleteAdministrador(id); } @Patch(':id') - @UseGuards(JwtAuthGuard) + @UseGuards(RolesGuard) + @UseGuards(JwtValidationGuard) + @Roles('administrador') @AdministradorApiDocumentation.ApiUpdate updateAdministrador( @Param('id', ParseIntPipe) id: number, diff --git a/src/administrador/administrador.service.ts b/src/administrador/administrador.service.ts index 818188e..2f6f703 100644 --- a/src/administrador/administrador.service.ts +++ b/src/administrador/administrador.service.ts @@ -35,9 +35,10 @@ export class AdministradorService { // Crear y guardar el nuevo administrador const newAdministrador = this.administradorRepository.create({ + nombre_usuario: administrador.nombre_usuario, correo: administrador.correo, password: hashedPassword, - id_tipo_user: administrador.id_tipo_user, + tipoUser: { id_tipo_user: administrador.id_tipo_user }, // Asignar el tipo de usuario }); const savedAdministrador = @@ -48,10 +49,10 @@ export class AdministradorService { return result; } - async login(correo: string, password: string) { - // Buscar administrador por correo + async login(nombre_usuario: string, password: string) { + // Buscar administrador por nombre de usuario const administrador = await this.administradorRepository.findOne({ - where: { correo }, + where: { nombre_usuario }, relations: ['tipoUser'], }); @@ -76,24 +77,24 @@ export class AdministradorService { // Generar token JWT const payload = { - sub: administrador.id_admnistrador, + sub: administrador.id_administrador, correo: administrador.correo, - id_tipo_user: administrador.id_tipo_user, }; return { token: this.jwtService.sign(payload), + tipo_usuario: administrador.tipoUser.tipo, }; } async changePassword( - id_admnistrador: number, + id_administrador: number, currentPassword: string, newPassword: string, ) { // Buscar administrador const administrador = await this.administradorRepository.findOne({ - where: { id_admnistrador }, + where: { id_administrador }, }); if (!administrador) { @@ -131,23 +132,17 @@ export class AdministradorService { return this.administradorRepository.find({ relations: ['tipoUser'], select: { - id_admnistrador: true, + nombre_usuario: true, + id_administrador: true, correo: true, - id_tipo_user: true, }, }); } - async getAdministrador(id_admnistrador: number) { + async getAdminByIdOrFail(id_administrador: number) { const administradorFound = await this.administradorRepository.findOne({ where: { - id_admnistrador, - }, - relations: ['tipoUser'], - select: { - id_admnistrador: true, - correo: true, - id_tipo_user: true, + id_administrador, }, }); @@ -161,9 +156,20 @@ export class AdministradorService { return administradorFound; } - async deleteAdministrador(id_admnistrador: number) { + async getAdminById(id_administrador: number) { + const administradorFound = await this.administradorRepository.findOne({ + where: { + id_administrador, + }, + relations: ['tipoUser'], + }); + + return administradorFound; + } + + async deleteAdministrador(id_administrador: number) { const result = await this.administradorRepository.delete({ - id_admnistrador, + id_administrador, }); if (result.affected === 0) { @@ -177,12 +183,12 @@ export class AdministradorService { } async updateAdministrador( - id_admnistrador: number, + id_administrador: number, administrador: UpdateAdministradorDto, ) { const administradorFound = await this.administradorRepository.findOne({ where: { - id_admnistrador, + id_administrador, }, }); diff --git a/src/administrador/administrador.documentation.ts b/src/administrador/docs/administrador.documentation.ts similarity index 97% rename from src/administrador/administrador.documentation.ts rename to src/administrador/docs/administrador.documentation.ts index 207ade5..cae5844 100644 --- a/src/administrador/administrador.documentation.ts +++ b/src/administrador/docs/administrador.documentation.ts @@ -10,9 +10,7 @@ import { applyDecorators } from '@nestjs/common'; export class AdministradorApiDocumentation { // Decorador para toda la clase del controlador - static ApiController = applyDecorators( - ApiTags('Administrador'), - ); + static ApiController = applyDecorators(ApiTags('Administrador')); // Documentación para crear un administrador static ApiCreate = applyDecorators( ApiOperation({ @@ -26,6 +24,11 @@ export class AdministradorApiDocumentation { type: 'object', required: ['correo', 'password', 'id_tipo_user'], properties: { + nombre_usuario: { + type: 'string', + example: 'mike', + description: 'Nombre de usuario del administrador', + }, correo: { type: 'string', format: 'email', diff --git a/src/administrador/dto/create-administrador.dto.ts b/src/administrador/dto/create-administrador.dto.ts index 9efa3d5..c970b27 100644 --- a/src/administrador/dto/create-administrador.dto.ts +++ b/src/administrador/dto/create-administrador.dto.ts @@ -2,18 +2,31 @@ import { IsEmail, IsNotEmpty, IsNumber, MinLength } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class CreateAdministradorDto { - @ApiProperty({ example: 'admin@example.com', description: 'Correo del administrador' }) - @IsEmail({}, { message: 'El correo no es válido' }) - @IsNotEmpty({ message: 'El correo es requerido' }) - correo: string; + @ApiProperty({ + example: 'admin@example.com', + description: 'Correo del administrador', + }) + @IsEmail({}, { message: 'El correo no es válido' }) + @IsNotEmpty({ message: 'El correo es requerido' }) + correo: string; - @ApiProperty({ example: 'Password123', description: 'Contraseña del administrador' }) - @MinLength(6, { message: 'La contraseña debe tener al menos 6 caracteres' }) - @IsNotEmpty({ message: 'La contraseña es requerida' }) - password: string; + @ApiProperty({ + example: 'mike', + description: 'Nombre de usuario del administrador', + }) + @IsNotEmpty({ message: 'El nombre de usuario es requerido' }) + nombre_usuario: string; - @ApiProperty({ example: 1, description: 'ID del tipo de usuario' }) - @IsNumber({}, { message: 'El tipo de usuario debe ser un número' }) - @IsNotEmpty({ message: 'El tipo de usuario es requerido' }) - id_tipo_user: number; -} \ No newline at end of file + @ApiProperty({ + example: 'Password123', + description: 'Contraseña del administrador', + }) + @MinLength(6, { message: 'La contraseña debe tener al menos 6 caracteres' }) + @IsNotEmpty({ message: 'La contraseña es requerida' }) + password: string; + + @ApiProperty({ example: 1, description: 'ID del tipo de usuario' }) + @IsNumber({}, { message: 'El tipo de usuario debe ser un número' }) + @IsNotEmpty({ message: 'El tipo de usuario es requerido' }) + id_tipo_user: number; +} diff --git a/src/administrador/dto/login-administrador.dto.ts b/src/administrador/dto/login-administrador.dto.ts index 79de46f..e57530a 100644 --- a/src/administrador/dto/login-administrador.dto.ts +++ b/src/administrador/dto/login-administrador.dto.ts @@ -1,13 +1,18 @@ -import { IsEmail, IsNotEmpty, MinLength } from 'class-validator'; +import { IsEmail, IsNotEmpty } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; export class LoginAdministradorDto { - @ApiProperty({ example: 'admin@example.com', description: 'Correo del administrador' }) - @IsEmail({}, { message: 'El correo no es válido' }) - @IsNotEmpty({ message: 'El correo es requerido' }) - correo: string; + @ApiProperty({ + example: 'mike', + description: 'Nombre de usuario del administrador', + }) + @IsNotEmpty({ message: 'El nombre de usuario es requerido' }) + nombre_usuario: string; - @ApiProperty({ example: 'Password123', description: 'Contraseña del administrador' }) - @IsNotEmpty({ message: 'La contraseña es requerida' }) - password: string; -} \ No newline at end of file + @ApiProperty({ + example: 'Password123', + description: 'Contraseña del administrador', + }) + @IsNotEmpty({ message: 'La contraseña es requerida' }) + password: string; +} diff --git a/src/administrador/entities/administrador.entity.ts b/src/administrador/entities/administrador.entity.ts index eb639a0..fe81988 100644 --- a/src/administrador/entities/administrador.entity.ts +++ b/src/administrador/entities/administrador.entity.ts @@ -1,10 +1,19 @@ import { TipoUser } from 'src/tipo_user/entities/tipo_user.entity'; -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; @Entity('administrador') export class Administrador { @PrimaryGeneratedColumn() - id_admnistrador: number; + id_administrador: number; + + @Column({ unique: true }) + nombre_usuario: string; @Column({ unique: true }) correo: string; @@ -12,9 +21,10 @@ export class Administrador { @Column() password: string; - @Column() + @Column({ type: 'int' }) id_tipo_user: number; @ManyToOne(() => TipoUser, (tipoUser) => tipoUser.administrador) - tipoUser: TipoUser[]; + @JoinColumn({ name: 'id_tipo_user' }) + tipoUser: TipoUser; } diff --git a/src/app.module.ts b/src/app.module.ts index 649d94d..471d82b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -28,6 +28,7 @@ import { ParticipanteEventoModule } from './participante_evento/participante_eve import { ValidacionesModule } from './validaciones/validaciones.module'; import { AlumnosModule } from './alumnos/alumnos.module'; import { TrabajadoresModule } from './trabajadores/trabajadores.module'; +import { TipoEventoModule } from './tipo_evento/tipo_evento.module'; @Module({ imports: [ @@ -62,6 +63,7 @@ import { TrabajadoresModule } from './trabajadores/trabajadores.module'; SeccionModule, SeccionPreguntaModule, TipoCuestionarioModule, + TipoEventoModule, TipoPreguntaModule, TipoUserModule, ValidacionesModule, diff --git a/src/auth/decorators/roles.decorator.ts b/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..26c0987 --- /dev/null +++ b/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: ('administrador' | 'staff')[]) => + SetMetadata(ROLES_KEY, roles); diff --git a/src/auth/guards/jwt-validation.guard.ts b/src/auth/guards/jwt-validation.guard.ts new file mode 100644 index 0000000..0b4e6bb --- /dev/null +++ b/src/auth/guards/jwt-validation.guard.ts @@ -0,0 +1,52 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { AdministradorService } from 'src/administrador/administrador.service'; + +@Injectable() +export class JwtValidationGuard implements CanActivate { + constructor( + private jwtService: JwtService, + private administradorService: AdministradorService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const authHeader = request.headers.authorization; + + if (!authHeader) { + throw new UnauthorizedException('Token no proporcionado'); + } + + const token = authHeader.split(' ')[1]; + + try { + const decodedToken = this.jwtService.verify(token, { + secret: process.env.JWT_SECRET, + }); + + const administrador = await this.administradorService.getAdminById( + decodedToken.sub, + ); + + if (!administrador) { + throw new UnauthorizedException('Usuario no encontrado'); + } + + request.user = administrador; + } catch (error) { + if (error.name === 'TokenExpiredError') { + throw new UnauthorizedException( + `La sesión ha expirado, inicia sesión nuevamente.`, + ); + } + throw new UnauthorizedException('Token inválido'); + } + + return true; + } +} diff --git a/src/auth/guards/roles.guard.ts b/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..e7f6450 --- /dev/null +++ b/src/auth/guards/roles.guard.ts @@ -0,0 +1,46 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.get( + ROLES_KEY, + context.getHandler(), + ); + if (!requiredRoles) return true; // Si no hay roles definidos, permitir acceso. + + const request = context.switchToHttp().getRequest(); + const user = request.user; // Usuario ya validado en JwtValidationGuard + + if (!user) { + throw new ForbiddenException('Usuario no autenticado'); + } + + // Obtener el tipo de usuario + const tipo_usuario = user.tipoUser.tipo; + + if (!tipo_usuario) { + throw new ForbiddenException('No se pudo determinar el tipo de usuario'); + } + + // Validar si el usuario tiene uno de los roles requeridos + const hasRole = requiredRoles.includes(tipo_usuario); + + if (!hasRole) { + throw new ForbiddenException( + 'No tienes permisos para acceder a este recurso', + ); + } + + return true; + } +} diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index 20fb513..7d3658d 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -19,15 +19,14 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } async validate(payload: any) { - const { sub: id_admnistrador } = payload; - + const { sub: id_administrador } = payload; + const administrador = await this.administradorRepository.findOne({ - where: { id_admnistrador }, + where: { id_administrador }, select: { - id_admnistrador: true, + id_administrador: true, correo: true, - id_tipo_user: true - } + }, }); if (!administrador) { @@ -36,4 +35,4 @@ export class JwtStrategy extends PassportStrategy(Strategy) { return administrador; } -} \ No newline at end of file +} diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index f4134c4..a4ff6c8 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -67,6 +67,7 @@ export class CuestionarioService { cuestionario.fecha_fin = new Date(createCuestionarioDto.fecha_fin); cuestionario.id_tipo_cuestionario = createCuestionarioDto.id_tipo_cuestionario; + cuestionario.id_tipo_evento = createCuestionarioDto.id_tipo_evento; cuestionario.contador_secciones = createCuestionarioDto.secciones?.length || 0; cuestionario.editable = true; @@ -227,6 +228,7 @@ export class CuestionarioService { fecha_inicio: new Date(cuestionario.fecha_inicio), fecha_fin: new Date(cuestionario.fecha_fin), id_tipo_cuestionario: cuestionario.id_tipo_cuestionario, + id_tipo_evento: cuestionario.id_tipo_evento, contador_secciones: cuestionario.secciones?.length || 0, editable: true, id_evento: eventoExistente.id_evento, // Asociar el evento creado diff --git a/src/cuestionario/dto/create-cuestionario.dto.ts b/src/cuestionario/dto/create-cuestionario.dto.ts index f90ee44..0cb2f34 100644 --- a/src/cuestionario/dto/create-cuestionario.dto.ts +++ b/src/cuestionario/dto/create-cuestionario.dto.ts @@ -63,6 +63,14 @@ export class CreateCuestionarioDto { @IsNumber() id_tipo_cuestionario: number; + @ApiProperty({ + description: 'ID del tipo de evento', + example: 1, + }) + @IsOptional() + @IsNumber() + id_tipo_evento: number; + @ApiProperty({ description: 'Cupo máximo de participantes', example: 100, diff --git a/src/cuestionario/entities/cuestionario.entity.ts b/src/cuestionario/entities/cuestionario.entity.ts index 4521691..c4e569e 100644 --- a/src/cuestionario/entities/cuestionario.entity.ts +++ b/src/cuestionario/entities/cuestionario.entity.ts @@ -11,6 +11,7 @@ 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'; +import { TipoEvento } from 'src/tipo_evento/entities/tipo_evento.entity'; @Entity('cuestionario') export class Cuestionario { @@ -44,14 +45,13 @@ export class Cuestionario { @Column({ type: 'int' }) id_tipo_cuestionario: number; + @Column({ type: 'int', nullable: true }) + id_tipo_evento: number; + @Column({ type: 'int' }) id_evento: number; // 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; @@ -62,6 +62,12 @@ export class Cuestionario { @JoinColumn({ name: 'id_tipo_cuestionario' }) tipoCuestionario: TipoCuestionario; + @ManyToOne(() => TipoEvento, (tipo) => tipo.cuestionarios, { + eager: true, + }) + @JoinColumn({ name: 'id_tipo_evento' }) + tipoEvento: TipoEvento; + @OneToMany( () => CuestionarioSeccion, (cuestionarioSeccion) => cuestionarioSeccion.cuestionario, diff --git a/src/cuestionario_respondido/cuestionario_respondido.module.ts b/src/cuestionario_respondido/cuestionario_respondido.module.ts index b0ba224..e006f18 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.module.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.module.ts @@ -5,6 +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 { QrModule } from '../qr/qr.module'; import { Participante } from '../participante/entities/participante.entity'; import { Cuestionario } from '../cuestionario/entities/cuestionario.entity'; import { Pregunta } from '../pregunta/entities/pregunta.entity'; @@ -29,6 +30,7 @@ import { ValidacionesModule } from '../validaciones/validaciones.module'; CuestionarioModule, ParticipanteModule, ValidacionesModule, + QrModule, ], controllers: [CuestionarioRespondidoController], providers: [CuestionarioRespondidoService], diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index fdc1a1e..9f3f426 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -17,6 +17,7 @@ import { RespuestaParticipanteCerrada } from '../respuesta_participante_cerrada/ import { PreguntaOpcion } from '../pregunta_opcion/entities/pregunta_opcion.entity'; import { Opcion } from '../opcion/entities/opcion.entity'; import { ValidadorRespuestasService } from '../validaciones/validador-respuestas.service'; +import { QrTokenService } from '../qr/qr-token.service'; import axios from 'axios'; import * as QRCode from 'qrcode'; import { CuestionarioService } from 'src/cuestionario/cuestionario.service'; @@ -39,6 +40,7 @@ export class CuestionarioRespondidoService { private dataSource: DataSource, private validadorRespuestasService: ValidadorRespuestasService, private cuestionarioService: CuestionarioService, + private qrTokenService: QrTokenService, ) {} create(createCuestionarioRespondidoDto: CreateCuestionarioRespondidoDto) { @@ -99,22 +101,25 @@ export class CuestionarioRespondidoService { relations: ['cuestionario', 'participante'], }, ); + if (cuestionarioRespondidoExistente) { await queryRunner.rollbackTransaction(); + let qrBuffer: Buffer | undefined; + let qrToken: string | undefined; + // Aunque ya haya respondido, enviamos el correo nuevamente if (cuestionario && cuestionario.evento) { try { - // Generar datos para el QR - const datosQR = { - id_participante: participante.id_participante, - id_evento: cuestionario.evento.id_evento, - id_cuestionario: cuestionario.id_cuestionario, - }; + // Generar token para el QR + qrToken = this.qrTokenService.generateQrToken( + participante.id_participante, + cuestionario.evento.id_evento, + cuestionario.id_cuestionario, + ); - // Generar código QR - const qrJsonData = JSON.stringify(datosQR); - const qrBuffer = await QRCode.toBuffer(qrJsonData); + // Generar código QR con el token + qrBuffer = await QRCode.toBuffer(qrToken); // Enviar correo con el QR const urlApiCorreos = process.env.url_api_correos; @@ -167,14 +172,10 @@ export class CuestionarioRespondidoService { return { success: true, + registrado: true, message: `El participante con correo ${submitDto.correo} ya había respondido el cuestionario ${cuestionario.nombre_form}. Se ha reenviado el correo con el código QR.`, - datos_qr: cuestionario.evento - ? { - id_participante: participante.id_participante, - id_evento: cuestionario.evento.id_evento, - id_cuestionario: cuestionario.id_cuestionario, - } - : undefined, + qr_token: qrToken, + qr_buffer: qrBuffer ? qrBuffer.toString('base64') : undefined, }; } @@ -286,6 +287,9 @@ export class CuestionarioRespondidoService { id_cuestionario: number; } | null = null; + let qrBuffer: Buffer | undefined; + let qrToken: string | undefined; + // Capturamos los datos para el QR if (cuestionario.evento && cuestionario.evento.id_evento) { datosQR = { @@ -331,9 +335,14 @@ export class CuestionarioRespondidoService { ); } - // Generar código QR - const qrJsonData = JSON.stringify(datosQR); - const qrBuffer = await QRCode.toBuffer(qrJsonData); + // Generar token para el QR + qrToken = this.qrTokenService.generateQrToken( + participante.id_participante, + cuestionario.evento.id_evento, + cuestionario.id_cuestionario, + ); + + qrBuffer = await QRCode.toBuffer(qrToken); // Enviar correo con el QR const urlApiCorreos = process.env.url_api_correos; @@ -388,9 +397,12 @@ export class CuestionarioRespondidoService { // Preparar respuesta const response: any = { success: true, + registrado: false, message: 'Respuestas guardadas correctamente', cuestionarioRespondidoId: savedCuestionarioRespondido.idCuestionarioRespondido, + qr_token: qrToken, + qr_buffer: qrBuffer ? qrBuffer.toString('base64') : undefined, }; // Incluir IDs para generar QR si hay evento asociado diff --git a/src/db/typeorm.config.ts b/src/db/typeorm.config.ts index 1ae25ac..c50a53e 100644 --- a/src/db/typeorm.config.ts +++ b/src/db/typeorm.config.ts @@ -20,6 +20,7 @@ import { SeccionPregunta } from 'src/seccion_pregunta/entities/seccion_pregunta. 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'; +import { TipoEvento } from 'src/tipo_evento/entities/tipo_evento.entity'; export const createMainDbConfig = async ( configService: ConfigService, @@ -47,6 +48,7 @@ export const createMainDbConfig = async ( Seccion, SeccionPregunta, TipoCuestionario, + TipoEvento, TipoPregunta, TipoUser, ], @@ -54,7 +56,7 @@ export const createMainDbConfig = async ( retryDelay: 3000, connectTimeout: 30000, - synchronize: configService.get('SYNCHRONIZE') === 'true', + synchronize: configService.get('SYNCHRONIZE') === 'false', dropSchema: configService.get('DROP_SCHEMA') === 'true', }); diff --git a/src/evento/dto/outpus.dto.ts b/src/evento/dto/outpus.dto.ts index de1e392..a7d3bf8 100644 --- a/src/evento/dto/outpus.dto.ts +++ b/src/evento/dto/outpus.dto.ts @@ -1,5 +1,6 @@ import { Expose, Type } from 'class-transformer'; import { TipoCuestionarioOutputDto } from 'src/tipo_cuestionario/dto/outputs.dto'; +import { TipoEventoOutputDto } from 'src/tipo_evento/dto/outputs.dto'; export class CuestionarioConCupoDto { @Expose() @@ -35,6 +36,10 @@ export class CuestionarioConCupoDto { @Expose() @Type(() => TipoCuestionarioOutputDto) tipoCuestionario: TipoCuestionarioOutputDto; + + @Expose() + @Type(() => TipoEventoOutputDto) + tipoEvento: TipoEventoOutputDto; } export class EventoConCuestionariosDto { diff --git a/src/evento/entities/evento.entity.ts b/src/evento/entities/evento.entity.ts index d297f7e..f6195a0 100644 --- a/src/evento/entities/evento.entity.ts +++ b/src/evento/entities/evento.entity.ts @@ -1,5 +1,13 @@ import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity'; -import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +import { TipoEvento } from 'src/tipo_evento/entities/tipo_evento.entity'; +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; @Entity() export class Evento { @@ -29,4 +37,10 @@ export class Evento { @OneToMany(() => Cuestionario, (cuestionario) => cuestionario.evento) cuestionarios: Cuestionario[]; + + @ManyToOne(() => TipoEvento, (tipo) => tipo.eventos, { + eager: true, + }) + @JoinColumn({ name: 'id_tipo_evento' }) + tipoEvento: TipoEvento; } diff --git a/src/participante_evento/dto/registrar-asistencia-qr.dto.ts b/src/participante_evento/dto/registrar-asistencia-qr.dto.ts new file mode 100644 index 0000000..dcf6033 --- /dev/null +++ b/src/participante_evento/dto/registrar-asistencia-qr.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class RegistrarAsistenciaQrDto { + @ApiProperty({ + description: 'Token JWT del código QR escaneado', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }) + @IsString() + @IsNotEmpty() + qr_token: string; +} diff --git a/src/participante_evento/participante_evento.controller.ts b/src/participante_evento/participante_evento.controller.ts index 5e6b26c..8a7681a 100644 --- a/src/participante_evento/participante_evento.controller.ts +++ b/src/participante_evento/participante_evento.controller.ts @@ -12,6 +12,7 @@ import { ParticipanteEventoService } from './participante_evento.service'; import { ParticipanteEvento } from './entities/participante_evento.entity'; import { CreateParticipanteEventoDto } from './dto/create-participante_evento.dto'; import { UpdateParticipanteEventoDto } from './dto/update.participante_evento.dto'; +import { RegistrarAsistenciaQrDto } from './dto/registrar-asistencia-qr.dto'; import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; @ApiTags('Participantes-Eventos') @@ -20,7 +21,11 @@ export class ParticipanteEventoController { constructor(private participanteEventoService: ParticipanteEventoService) {} @ApiOperation({ summary: 'Obtener participantes por evento' }) - @ApiParam({ name: 'id_cuestionario', description: 'ID del evento', type: 'number' }) + @ApiParam({ + name: 'id_cuestionario', + description: 'ID del evento', + type: 'number', + }) @ApiResponse({ status: 200, description: 'Lista de participantes que están registrados en el evento', @@ -220,6 +225,44 @@ export class ParticipanteEventoController { ); } + @ApiOperation({ + summary: 'Registrar asistencia usando token QR', + }) + @ApiResponse({ + status: 200, + description: 'Asistencia registrada correctamente mediante token QR', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' }, + data: { + type: 'object', + properties: { + participante: { type: 'string' }, + fecha_asistencia: { type: 'string', format: 'date-time' }, + id_evento: { type: 'number' }, + id_cuestionario: { type: 'number' }, + }, + }, + }, + }, + }) + @ApiResponse({ + status: 400, + description: 'Token QR inválido o expirado', + }) + @ApiResponse({ + status: 404, + description: 'Participante no encontrado o no registrado en el evento', + }) + @Post('asistencia-qr') + registrarAsistenciaConToken(@Body() body: { qr_token: string }) { + return this.participanteEventoService.registrarAsistenciaConToken( + body.qr_token, + ); + } + @ApiOperation({ summary: 'Eliminar un registro participante-evento' }) @ApiParam({ name: 'id', description: 'ID del participante', type: 'number' }) @ApiResponse({ diff --git a/src/participante_evento/participante_evento.module.ts b/src/participante_evento/participante_evento.module.ts index 3a957f4..d87fdb7 100644 --- a/src/participante_evento/participante_evento.module.ts +++ b/src/participante_evento/participante_evento.module.ts @@ -6,11 +6,13 @@ 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'; +import { QrModule } from 'src/qr/qr.module'; @Module({ imports: [ TypeOrmModule.forFeature([ParticipanteEvento, Evento, Participante]), CuestionarioModule, + QrModule, ], controllers: [ParticipanteEventoController], providers: [ParticipanteEventoService], diff --git a/src/participante_evento/participante_evento.service.ts b/src/participante_evento/participante_evento.service.ts index 2b1d71e..7b570aa 100644 --- a/src/participante_evento/participante_evento.service.ts +++ b/src/participante_evento/participante_evento.service.ts @@ -7,6 +7,7 @@ import { UpdateParticipanteEventoDto } from './dto/update.participante_evento.dt import { Evento } from 'src/evento/entities/evento.entity'; import { Participante } from 'src/participante/entities/participante.entity'; import { CuestionarioService } from 'src/cuestionario/cuestionario.service'; +import { QrTokenService } from 'src/qr/qr-token.service'; @Injectable() export class ParticipanteEventoService { @@ -17,6 +18,7 @@ export class ParticipanteEventoService { @InjectRepository(Participante) private participanteRepository: Repository, private readonly cuestionarioService: CuestionarioService, + private readonly qrTokenService: QrTokenService, ) {} async createParticipanteEvento( @@ -175,4 +177,83 @@ export class ParticipanteEventoService { participante_eventoFound.asistio = true; return this.participanteEventoRepository.save(participante_eventoFound); } + + /** + * Registra la asistencia usando un token QR + * @param qrToken Token JWT del código QR + * @returns Resultado del registro de asistencia + */ + async registrarAsistenciaConToken(qrToken: string) { + // Validar el token QR + const tokenData = this.qrTokenService.validateQrToken(qrToken); + + if (!tokenData) { + throw new HttpException( + 'Token QR inválido o expirado', + HttpStatus.BAD_REQUEST, + ); + } + + const { id_participante, id_evento, id_cuestionario } = tokenData; + + // Verificar que el participante existe + const participante = await this.participanteRepository.findOne({ + where: { id_participante }, + }); + + if (!participante) { + throw new HttpException( + `Participante con ID ${id_participante} no encontrado`, + HttpStatus.NOT_FOUND, + ); + } + + // Buscar el registro participante-evento + const participanteEvento = await this.participanteEventoRepository.findOne({ + where: { + id_participante, + id_cuestionario, + }, + relations: ['participante'], + }); + + if (!participanteEvento) { + throw new HttpException( + 'El participante no está registrado en este evento', + HttpStatus.NOT_FOUND, + ); + } + + // Verificar si ya había registrado asistencia + if (participanteEvento.asistio && participanteEvento.fecha_asistencia) { + return { + success: true, + message: + 'El participante ya había registrado su asistencia previamente', + data: { + participante: participante.correo, + fecha_asistencia_previa: participanteEvento.fecha_asistencia, + fecha_actual: new Date(), + }, + }; + } + + // Registrar la asistencia + participanteEvento.fecha_asistencia = new Date(); + participanteEvento.asistio = true; + + const resultado = + await this.participanteEventoRepository.save(participanteEvento); + + return { + success: true, + message: 'Asistencia registrada exitosamente', + data: { + participante: participante.correo, + fecha_asistencia: resultado.fecha_asistencia, + id_evento, + id_cuestionario, + }, + }; + } } diff --git a/src/qr/dto/validate-qr-token.dto.ts b/src/qr/dto/validate-qr-token.dto.ts new file mode 100644 index 0000000..e0c2a04 --- /dev/null +++ b/src/qr/dto/validate-qr-token.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsNotEmpty } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ValidateQrTokenDto { + @ApiProperty({ + description: 'Token JWT del código QR', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }) + @IsString() + @IsNotEmpty() + token: string; +} diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts new file mode 100644 index 0000000..f0792e1 --- /dev/null +++ b/src/qr/qr-token.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class QrTokenService { + constructor( + private jwtService: JwtService, + private configService: ConfigService, + ) {} + + /** + * Genera un token JWT específico para el QR de asistencia + * @param id_participante ID del participante + * @param id_evento ID del evento + * @param id_cuestionario ID del cuestionario + * @returns Token JWT codificado + */ + generateQrToken( + id_participante: number, + id_evento: number, + id_cuestionario: number, + ): string { + const payload = { + id_participante, + id_evento, + id_cuestionario, + type: 'qr_asistencia', + iat: Math.floor(Date.now() / 1000), + }; + + // Usar una clave secreta específica para QR o la misma que JWT general + const secret = + this.configService.get('QR_JWT_SECRET') || + this.configService.get('JWT_SECRET', 'tu_clave_secreta'); + + return this.jwtService.sign(payload, { + secret, + expiresIn: '30d', // El QR puede ser válido por 30 días + }); + } + + /** + * Valida y decodifica un token de QR + * @param token Token JWT a validar + * @returns Datos decodificados del token o null si es inválido + */ + validateQrToken(token: string): { + id_participante: number; + id_evento: number; + id_cuestionario: number; + type: string; + iat: number; + } | null { + try { + const secret = + this.configService.get('QR_JWT_SECRET') || + this.configService.get('JWT_SECRET', 'tu_clave_secreta'); + + const decoded = this.jwtService.verify(token, { secret }); + + // Verificar que sea un token de tipo QR de asistencia + if (decoded.type !== 'qr_asistencia') { + return null; + } + + return { + id_participante: decoded.id_participante, + id_evento: decoded.id_evento, + id_cuestionario: decoded.id_cuestionario, + type: decoded.type, + iat: decoded.iat, + }; + } catch (error) { + console.error('Error validando token QR:', error.message); + return null; + } + } + + /** + * Verifica si un token QR es válido y no ha expirado + * @param token Token a verificar + * @returns true si es válido, false si no + */ + isTokenValid(token: string): boolean { + const decoded = this.validateQrToken(token); + return decoded !== null; + } +} diff --git a/src/qr/qr.controller.ts b/src/qr/qr.controller.ts index 67b4ff9..bb6246c 100644 --- a/src/qr/qr.controller.ts +++ b/src/qr/qr.controller.ts @@ -10,14 +10,20 @@ import { Query, } from '@nestjs/common'; import { QrService } from './qr.service'; +import { QrTokenService } from './qr-token.service'; import { Qr } from './qr.entity'; import { CreateQrDto } from './dto/create-qr.dto'; import { UpdateQrDto } from './dto/update.qr.dto'; -import { ApiOperation, ApiParam, ApiQuery } from '@nestjs/swagger'; +import { ValidateQrTokenDto } from './dto/validate-qr-token.dto'; +import { ApiOperation, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger'; +@ApiTags('QR') @Controller('qr') export class QrController { - constructor(private qrService: QrService) {} + constructor( + private qrService: QrService, + private qrTokenService: QrTokenService, + ) {} @Get('generate') @ApiOperation({ summary: 'Genera un código QR a partir de un texto' }) @@ -31,16 +37,64 @@ export class QrController { } @Get('asistencia/:idParticipante/:idEvento') - @ApiOperation({ summary: 'Genera un código QR para asistencia con IDs de participante y evento' }) + @ApiOperation({ + summary: + 'Genera un código QR para asistencia con IDs de participante y evento', + }) @ApiParam({ name: 'idParticipante', description: 'ID del participante' }) @ApiParam({ name: 'idEvento', description: 'ID del evento' }) async generateAsistenciaQR( @Param('idParticipante', ParseIntPipe) idParticipante: number, - @Param('idEvento', ParseIntPipe) idEvento: number + @Param('idEvento', ParseIntPipe) idEvento: number, ): Promise { return this.qrService.generateAsistenciaQR(idParticipante, idEvento); } + @Post('validate-token') + @ApiOperation({ summary: 'Valida un token de QR para asistencia' }) + async validateQrToken(@Body() validateDto: ValidateQrTokenDto) { + const tokenData = this.qrTokenService.validateQrToken(validateDto.token); + + if (!tokenData) { + return { + valid: false, + message: 'Token inválido o expirado', + }; + } + + return { + valid: true, + data: tokenData, + message: 'Token válido', + }; + } + + @Get('generate-token/:idParticipante/:idEvento/:idCuestionario') + @ApiOperation({ summary: 'Genera un token JWT para QR de asistencia' }) + @ApiParam({ name: 'idParticipante', description: 'ID del participante' }) + @ApiParam({ name: 'idEvento', description: 'ID del evento' }) + @ApiParam({ name: 'idCuestionario', description: 'ID del cuestionario' }) + async generateQrToken( + @Param('idParticipante', ParseIntPipe) idParticipante: number, + @Param('idEvento', ParseIntPipe) idEvento: number, + @Param('idCuestionario', ParseIntPipe) idCuestionario: number, + ) { + const token = this.qrTokenService.generateQrToken( + idParticipante, + idEvento, + idCuestionario, + ); + + return { + token, + qr_data: { + id_participante: idParticipante, + id_evento: idEvento, + id_cuestionario: idCuestionario, + }, + }; + } + @Get() getQrs(): Promise { return this.qrService.getQrs(); diff --git a/src/qr/qr.module.ts b/src/qr/qr.module.ts index 1a6040e..e7471b9 100644 --- a/src/qr/qr.module.ts +++ b/src/qr/qr.module.ts @@ -1,12 +1,16 @@ import { Module } from '@nestjs/common'; import { QrService } from './qr.service'; import { QrController } from './qr.controller'; +import { QrTokenService } from './qr-token.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Qr } from './qr.entity'; +import { AuthModule } from '../auth/auth.module'; +import { ConfigModule } from '@nestjs/config'; @Module({ - imports: [TypeOrmModule.forFeature([Qr])], + imports: [TypeOrmModule.forFeature([Qr]), AuthModule, ConfigModule], controllers: [QrController], - providers: [QrService] + providers: [QrService, QrTokenService], + exports: [QrTokenService], }) export class QrModule {} diff --git a/src/tipo_cuestionario/dto/outputs.dto.ts b/src/tipo_cuestionario/dto/outputs.dto.ts index 1f225ee..c21eabf 100644 --- a/src/tipo_cuestionario/dto/outputs.dto.ts +++ b/src/tipo_cuestionario/dto/outputs.dto.ts @@ -1,9 +1,9 @@ import { Expose } from 'class-transformer'; export class TipoCuestionarioOutputDto { - @Expose() - id_tipo_cuestionario: number; + @Expose() + id_tipo_cuestionario: number; - @Expose() - tipo_cuestionario: string; -} \ No newline at end of file + @Expose() + tipo_cuestionario: string; +} diff --git a/src/tipo_evento/dto/create-tipo_evento.dto.ts b/src/tipo_evento/dto/create-tipo_evento.dto.ts new file mode 100644 index 0000000..83453f6 --- /dev/null +++ b/src/tipo_evento/dto/create-tipo_evento.dto.ts @@ -0,0 +1,8 @@ +import { IsEnum, IsNotEmpty } from 'class-validator'; +import { TipoEventoEnum } from '../entities/tipo_evento.entity'; + +export class CreateTipoEventoDto { + @IsNotEmpty() + @IsEnum(TipoEventoEnum) + tipo_evento: TipoEventoEnum; +} diff --git a/src/tipo_evento/dto/outputs.dto.ts b/src/tipo_evento/dto/outputs.dto.ts new file mode 100644 index 0000000..23c2bec --- /dev/null +++ b/src/tipo_evento/dto/outputs.dto.ts @@ -0,0 +1,9 @@ +import { Expose } from 'class-transformer'; + +export class TipoEventoOutputDto { + @Expose() + id_tipo_evento: number; + + @Expose() + tipo_evento: string; +} diff --git a/src/tipo_evento/dto/update-tipo_evento.dto.ts b/src/tipo_evento/dto/update-tipo_evento.dto.ts new file mode 100644 index 0000000..403e1b8 --- /dev/null +++ b/src/tipo_evento/dto/update-tipo_evento.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateTipoEventoDto } from './create-tipo_evento.dto'; + +export class UpdateTipoEventoDto extends PartialType(CreateTipoEventoDto) {} diff --git a/src/tipo_evento/entities/tipo_evento.entity.ts b/src/tipo_evento/entities/tipo_evento.entity.ts new file mode 100644 index 0000000..ff3ce17 --- /dev/null +++ b/src/tipo_evento/entities/tipo_evento.entity.ts @@ -0,0 +1,34 @@ +import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity'; +import { Evento } from 'src/evento/entities/evento.entity'; +import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm'; + +export enum TipoEventoEnum { + ACADEMICO = 'Académico', + TALLER_CAPACITACION = 'Taller / Capacitación', + CONFERENCIA_CHARLA = 'Conferencia / Charla', + PANEL_MESA_REDONDA = 'Panel / Mesa redonda', + FERIA_EXPO = 'Feria / Expo', + NETWORKING = 'Networking / Vinculación', + ENTREVISTA = 'Entrevista / Sesión 1:1', + CULTURAL_ARTISTICO = 'Cultural / Artístico', + DEPORTIVO = 'Deportivo / Recreativo', + INSTITUCIONAL = 'Institucional / Ceremonial', + SOCIAL_COMUNITARIO = 'Social / Comunitario', + ANIVERSARIO = 'Aniversario', + OTRO = 'Otro', +} + +@Entity('tipo_evento') +export class TipoEvento { + @PrimaryGeneratedColumn() + id_tipo_evento: number; + + @Column({ type: 'enum', enum: TipoEventoEnum }) + tipo_evento: TipoEventoEnum; + + @OneToMany(() => Cuestionario, (cuestionario) => cuestionario.tipoEvento) + cuestionarios: Cuestionario[]; + + @OneToMany(() => Evento, (evento) => evento.tipoEvento) + eventos: Evento[]; +} diff --git a/src/tipo_evento/tipo_evento.controller.ts b/src/tipo_evento/tipo_evento.controller.ts new file mode 100644 index 0000000..ad09009 --- /dev/null +++ b/src/tipo_evento/tipo_evento.controller.ts @@ -0,0 +1,46 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + ParseIntPipe, +} from '@nestjs/common'; +import { TipoEventoService } from './tipo_evento.service'; +import { CreateTipoEventoDto } from './dto/create-tipo_evento.dto'; +import { UpdateTipoEventoDto } from './dto/update-tipo_evento.dto'; + +@Controller('tipo-evento') +export class TipoEventoController { + constructor(private readonly tipoEventoService: TipoEventoService) {} + + @Post() + create(@Body() createTipoEventoDto: CreateTipoEventoDto) { + return this.tipoEventoService.create(createTipoEventoDto); + } + + @Get() + findAll() { + return this.tipoEventoService.findAll(); + } + + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number) { + return this.tipoEventoService.findOne(id); + } + + @Patch(':id') + update( + @Param('id', ParseIntPipe) id: number, + @Body() updateTipoEventoDto: UpdateTipoEventoDto, + ) { + return this.tipoEventoService.update(id, updateTipoEventoDto); + } + + @Delete(':id') + remove(@Param('id', ParseIntPipe) id: number) { + return this.tipoEventoService.remove(id); + } +} diff --git a/src/tipo_evento/tipo_evento.module.ts b/src/tipo_evento/tipo_evento.module.ts new file mode 100644 index 0000000..2c1e164 --- /dev/null +++ b/src/tipo_evento/tipo_evento.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TipoEvento } from './entities/tipo_evento.entity'; +import { TipoEventoController } from './tipo_evento.controller'; +import { TipoEventoService } from './tipo_evento.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([TipoEvento])], + controllers: [TipoEventoController], + providers: [TipoEventoService], + exports: [TipoEventoService], +}) +export class TipoEventoModule {} diff --git a/src/tipo_evento/tipo_evento.service.ts b/src/tipo_evento/tipo_evento.service.ts new file mode 100644 index 0000000..c142100 --- /dev/null +++ b/src/tipo_evento/tipo_evento.service.ts @@ -0,0 +1,98 @@ +import { + Injectable, + NotFoundException, + ConflictException, + OnModuleInit, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TipoEvento, TipoEventoEnum } from './entities/tipo_evento.entity'; +import { CreateTipoEventoDto } from './dto/create-tipo_evento.dto'; +import { UpdateTipoEventoDto } from './dto/update-tipo_evento.dto'; + +@Injectable() +export class TipoEventoService implements OnModuleInit { + constructor( + @InjectRepository(TipoEvento) + private tipoEventoRepository: Repository, + ) {} + + async onModuleInit() { + await this.seedTipoEventos(); + } + + private async seedTipoEventos() { + const enumValues = Object.values(TipoEventoEnum); + + for (const tipoEvento of enumValues) { + const existingTipo = await this.tipoEventoRepository.findOne({ + where: { tipo_evento: tipoEvento }, + }); + + if (!existingTipo) { + await this.tipoEventoRepository.save({ + tipo_evento: tipoEvento, + }); + } + } + } + + async create(createTipoEventoDto: CreateTipoEventoDto): Promise { + const existingTipo = await this.tipoEventoRepository.findOne({ + where: { tipo_evento: createTipoEventoDto.tipo_evento }, + }); + + if (existingTipo) { + throw new ConflictException('Este tipo de evento ya existe'); + } + + const tipoEvento = this.tipoEventoRepository.create(createTipoEventoDto); + return this.tipoEventoRepository.save(tipoEvento); + } + + async findAll(): Promise { + return this.tipoEventoRepository.find({ + order: { id_tipo_evento: 'ASC' }, + }); + } + + async findOne(id: number): Promise { + const tipoEvento = await this.tipoEventoRepository.findOne({ + where: { id_tipo_evento: id }, + }); + + if (!tipoEvento) { + throw new NotFoundException(`Tipo de evento con ID ${id} no encontrado`); + } + + return tipoEvento; + } + + async update( + id: number, + updateTipoEventoDto: UpdateTipoEventoDto, + ): Promise { + const tipoEvento = await this.findOne(id); + + if (updateTipoEventoDto.tipo_evento) { + const existingTipo = await this.tipoEventoRepository.findOne({ + where: { tipo_evento: updateTipoEventoDto.tipo_evento }, + }); + + if (existingTipo && existingTipo.id_tipo_evento !== id) { + throw new ConflictException('Este tipo de evento ya existe'); + } + } + + Object.assign(tipoEvento, updateTipoEventoDto); + return this.tipoEventoRepository.save(tipoEvento); + } + + async remove(id: number): Promise<{ message: string }> { + const tipoEvento = await this.findOne(id); + + await this.tipoEventoRepository.remove(tipoEvento); + + return { message: 'Tipo de evento eliminado exitosamente' }; + } +} diff --git a/src/tipo_user/entities/tipo_user.entity.ts b/src/tipo_user/entities/tipo_user.entity.ts index 53a3ed0..3f14f3c 100644 --- a/src/tipo_user/entities/tipo_user.entity.ts +++ b/src/tipo_user/entities/tipo_user.entity.ts @@ -2,6 +2,11 @@ import { Administrador } from 'src/administrador/entities/administrador.entity'; import { Participante } from 'src/participante/entities/participante.entity'; import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; +export enum TipoUsuarioEnum { + ADMINISTRADOR = 'administrador', + STAFF = 'staff', +} + @Entity() export class TipoUser { @PrimaryGeneratedColumn() diff --git a/src/tipo_user/tipo_user.service.ts b/src/tipo_user/tipo_user.service.ts index 8b372e8..c026e30 100644 --- a/src/tipo_user/tipo_user.service.ts +++ b/src/tipo_user/tipo_user.service.ts @@ -5,7 +5,7 @@ import { Injectable, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { TipoUser } from './entities/tipo_user.entity'; +import { TipoUser, TipoUsuarioEnum } 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'; @@ -23,6 +23,25 @@ export class TipoUserService { private administradorRepository: Repository, ) {} + async onModuleInit() { + await this.seedTipoEventos(); + } + + private async seedTipoEventos() { + const enumValues = Object.values(TipoUsuarioEnum); + + for (const tipoUsuario of enumValues) { + const existingTipo = await this.tipoUserRepository.findOne({ + where: { tipo: tipoUsuario }, + }); + + if (!existingTipo) { + await this.tipoUserRepository.save({ + tipo: tipoUsuario, + }); + } + } + } /* //Funcion para registrar usuario async registrarUsuario(dto: CreateTipoUserDto) { From 14ae95c00ef1b6b037c2a7b948bf718bdffa651e Mon Sep 17 00:00:00 2001 From: miguel Date: Tue, 9 Sep 2025 15:17:59 -0600 Subject: [PATCH 21/62] feat: update asistencia registration to use JWT token and modify TypeORM synchronization setting --- src/db/typeorm.config.ts | 2 +- .../participante_evento.controller.ts | 36 +++++++++++-------- .../participante_evento.service.ts | 6 +++- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/src/db/typeorm.config.ts b/src/db/typeorm.config.ts index c50a53e..950c71e 100644 --- a/src/db/typeorm.config.ts +++ b/src/db/typeorm.config.ts @@ -56,7 +56,7 @@ export const createMainDbConfig = async ( retryDelay: 3000, connectTimeout: 30000, - synchronize: configService.get('SYNCHRONIZE') === 'false', + synchronize: configService.get('SYNCHRONIZE') === 'true', dropSchema: configService.get('DROP_SCHEMA') === 'true', }); diff --git a/src/participante_evento/participante_evento.controller.ts b/src/participante_evento/participante_evento.controller.ts index 8a7681a..df24753 100644 --- a/src/participante_evento/participante_evento.controller.ts +++ b/src/participante_evento/participante_evento.controller.ts @@ -13,7 +13,13 @@ import { ParticipanteEvento } from './entities/participante_evento.entity'; import { CreateParticipanteEventoDto } from './dto/create-participante_evento.dto'; import { UpdateParticipanteEventoDto } from './dto/update.participante_evento.dto'; import { RegistrarAsistenciaQrDto } from './dto/registrar-asistencia-qr.dto'; -import { ApiTags, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiParam, + ApiResponse, + ApiBody, +} from '@nestjs/swagger'; @ApiTags('Participantes-Eventos') @Controller('participante-evento') @@ -203,25 +209,27 @@ export class ParticipanteEventoController { @ApiOperation({ summary: 'Registrar asistencia de un participante a un evento', }) - @ApiParam({ - name: 'idParticipante', - description: 'ID del participante', - type: 'number', + @ApiBody({ + description: 'Token JWT generado por el código QR', + schema: { + type: 'object', + properties: { + token: { type: 'string' }, + }, + example: { + token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }, + }, }) - @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, + @Post('asistencia') + registrarAsistencia(@Body() body: { token: string }) { + return this.participanteEventoService.registrarAsistenciaConToken( + body.token, ); } diff --git a/src/participante_evento/participante_evento.service.ts b/src/participante_evento/participante_evento.service.ts index 7b570aa..205fce9 100644 --- a/src/participante_evento/participante_evento.service.ts +++ b/src/participante_evento/participante_evento.service.ts @@ -160,7 +160,11 @@ export class ParticipanteEventoService { return this.participanteEventoRepository.save(participante_evento); } - async registrarAsistencia(id_participante: number, id_cuestionario: number) { + async registrarAsistencia( + id_participante: number, + id_cuestionario: number, + token: string, + ) { const participante_eventoFound = await this.participanteEventoRepository.findOne({ where: { From 6fc315450d9473506bdbaa1640657ed31a8d3bc0 Mon Sep 17 00:00:00 2001 From: jorge miguel Date: Tue, 9 Sep 2025 17:43:23 -0600 Subject: [PATCH 22/62] feat: add token validation endpoint and logic for QR token verification --- .../participante_evento.controller.ts | 6 ++++++ .../participante_evento.service.ts | 14 ++++++++++++++ src/qr/qr-token.service.ts | 2 ++ 3 files changed, 22 insertions(+) diff --git a/src/participante_evento/participante_evento.controller.ts b/src/participante_evento/participante_evento.controller.ts index df24753..bc450cb 100644 --- a/src/participante_evento/participante_evento.controller.ts +++ b/src/participante_evento/participante_evento.controller.ts @@ -233,6 +233,12 @@ export class ParticipanteEventoController { ); } + @Post('decode-token') + decodeToken(@Body() body: { token: string }) { + return this.participanteEventoService.validToken( + body.token, + ); + } @ApiOperation({ summary: 'Registrar asistencia usando token QR', }) diff --git a/src/participante_evento/participante_evento.service.ts b/src/participante_evento/participante_evento.service.ts index 205fce9..cbb2dab 100644 --- a/src/participante_evento/participante_evento.service.ts +++ b/src/participante_evento/participante_evento.service.ts @@ -260,4 +260,18 @@ export class ParticipanteEventoService { }, }; } + + async validToken(qrToken: string) { + // Validar el token QR + const tokenData = this.qrTokenService.validateQrToken(qrToken); + + if (!tokenData) { + throw new HttpException( + 'Token QR inválido o expirado', + HttpStatus.BAD_REQUEST, + ); + } + + return tokenData + } } diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index f0792e1..e5243c4 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -57,6 +57,8 @@ export class QrTokenService { this.configService.get('QR_JWT_SECRET') || this.configService.get('JWT_SECRET', 'tu_clave_secreta'); + console.log(secret) + const decoded = this.jwtService.verify(token, { secret }); // Verificar que sea un token de tipo QR de asistencia From 92851b240a0f011e6d1ac873d1a2f222fccca460 Mon Sep 17 00:00:00 2001 From: jorge miguel Date: Tue, 9 Sep 2025 18:03:59 -0600 Subject: [PATCH 23/62] feat: add endpoint to register participant attendance without token --- .../participante_evento.controller.ts | 29 +++++++++++++++++++ .../participante_evento.service.ts | 3 +- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/participante_evento/participante_evento.controller.ts b/src/participante_evento/participante_evento.controller.ts index bc450cb..8343ea9 100644 --- a/src/participante_evento/participante_evento.controller.ts +++ b/src/participante_evento/participante_evento.controller.ts @@ -233,12 +233,41 @@ export class ParticipanteEventoController { ); } + @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') + registrarAsistenciaSinToken( + @Param('idParticipante', ParseIntPipe) idParticipante: number, + @Param('idEvento', ParseIntPipe) idEvento: number, + ) { + return this.participanteEventoService.registrarAsistenciaSinToken( + idParticipante, + idEvento, + ); + } + + @ApiOperation({ + summary: 'Retornala info del token QR', + }) @Post('decode-token') decodeToken(@Body() body: { token: string }) { return this.participanteEventoService.validToken( body.token, ); } + @ApiOperation({ summary: 'Registrar asistencia usando token QR', }) diff --git a/src/participante_evento/participante_evento.service.ts b/src/participante_evento/participante_evento.service.ts index cbb2dab..548e2e6 100644 --- a/src/participante_evento/participante_evento.service.ts +++ b/src/participante_evento/participante_evento.service.ts @@ -160,10 +160,9 @@ export class ParticipanteEventoService { return this.participanteEventoRepository.save(participante_evento); } - async registrarAsistencia( + async registrarAsistenciaSinToken( id_participante: number, id_cuestionario: number, - token: string, ) { const participante_eventoFound = await this.participanteEventoRepository.findOne({ From f1fba6a6cf6429442f321febe3aaeb2dd3d613fd Mon Sep 17 00:00:00 2001 From: miguel Date: Wed, 10 Sep 2025 13:29:55 -0600 Subject: [PATCH 24/62] fix: update email content for event registration confirmation --- src/emails/registro-evento.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/emails/registro-evento.ts b/src/emails/registro-evento.ts index 7a544ad..bfbe999 100644 --- a/src/emails/registro-evento.ts +++ b/src/emails/registro-evento.ts @@ -13,8 +13,9 @@ export function generarHtmlCorreoAsistencia({ fechaInicioEvento?: string; fechaFinEvento?: string; }) { - const horarioEvento = fechaInicioEvento && fechaFinEvento - ? ` + const horarioEvento = + fechaInicioEvento && fechaFinEvento + ? `

    🕒 Horario del evento:
    @@ -22,7 +23,7 @@ export function generarHtmlCorreoAsistencia({

    ` - : ''; + : ''; return `
    @@ -33,7 +34,7 @@ export function generarHtmlCorreoAsistencia({

    - Favor de presentar este código QR (celular o impreso) en la mesa de registro. el día del evento, para validar su asistencia y tener derecho a la constancia. + Favor de presentar este código QR (en tu celular o impreso) en las mesas de juegos el día del evento, para participar por tu premio.

    From 6926e84df37193e02f492d4d4bb228c639527cbe Mon Sep 17 00:00:00 2001 From: santiago Date: Thu, 25 Sep 2025 19:58:08 +0200 Subject: [PATCH 25/62] Correcion de error al tomar asistencia con codigo qr --- .../participante_evento.service.ts | 4 +-- src/qr/qr-token.service.ts | 31 ++++++++++++++++--- src/qr/qr.module.ts | 3 +- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/participante_evento/participante_evento.service.ts b/src/participante_evento/participante_evento.service.ts index 548e2e6..99a1dcb 100644 --- a/src/participante_evento/participante_evento.service.ts +++ b/src/participante_evento/participante_evento.service.ts @@ -188,7 +188,7 @@ export class ParticipanteEventoService { */ async registrarAsistenciaConToken(qrToken: string) { // Validar el token QR - const tokenData = this.qrTokenService.validateQrToken(qrToken); + const tokenData = await this.qrTokenService.validateQrToken(qrToken); if (!tokenData) { throw new HttpException( @@ -196,7 +196,7 @@ export class ParticipanteEventoService { HttpStatus.BAD_REQUEST, ); } - + const { id_participante, id_evento, id_cuestionario } = tokenData; // Verificar que el participante existe diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index e5243c4..1317541 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -1,12 +1,17 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Asistencia } from 'src/asistencia/entities/asistencia.entity'; @Injectable() export class QrTokenService { constructor( private jwtService: JwtService, private configService: ConfigService, + @InjectRepository(Asistencia) + private readonly asistenciaRepository: Repository, ) {} /** @@ -45,13 +50,14 @@ export class QrTokenService { * @param token Token JWT a validar * @returns Datos decodificados del token o null si es inválido */ - validateQrToken(token: string): { - id_participante: number; + async validateQrToken(token: string): Promise<{ + id_participante + : number; id_evento: number; id_cuestionario: number; type: string; iat: number; - } | null { + } | null >{ try { const secret = this.configService.get('QR_JWT_SECRET') || @@ -61,6 +67,23 @@ export class QrTokenService { const decoded = this.jwtService.verify(token, { secret }); + let asistenciaEvento = await this.asistenciaRepository.findOne({ + where: { + id_participante: decoded.id_participante, + id_evento: decoded.id_evento, + } + }) + if(!asistenciaEvento){ + let newAsistencia=this.asistenciaRepository.create({estado:false,id_evento:decoded.id_evento, id_participante:decoded.id_participante}) + asistenciaEvento= await this.asistenciaRepository.save(newAsistencia) + } + + if (!asistenciaEvento?.estado) { + asistenciaEvento.estado = true + }else{ + throw new UnauthorizedException("El usuario ya se registró"); + } + // Verificar que sea un token de tipo QR de asistencia if (decoded.type !== 'qr_asistencia') { return null; diff --git a/src/qr/qr.module.ts b/src/qr/qr.module.ts index e7471b9..1bfdb76 100644 --- a/src/qr/qr.module.ts +++ b/src/qr/qr.module.ts @@ -6,9 +6,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Qr } from './qr.entity'; import { AuthModule } from '../auth/auth.module'; import { ConfigModule } from '@nestjs/config'; +import { ParticipanteEvento } from 'src/participante_evento/entities/participante_evento.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Qr]), AuthModule, ConfigModule], + imports: [TypeOrmModule.forFeature([Qr]), AuthModule, ConfigModule, ParticipanteEvento], controllers: [QrController], providers: [QrService, QrTokenService], exports: [QrTokenService], From 25872c544feef1d7889660d0f89b7386a866fb25 Mon Sep 17 00:00:00 2001 From: santiago Date: Thu, 25 Sep 2025 20:13:26 +0200 Subject: [PATCH 26/62] Se agregaron las dependencias --- src/qr/qr.module.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/qr/qr.module.ts b/src/qr/qr.module.ts index 1bfdb76..4865dd4 100644 --- a/src/qr/qr.module.ts +++ b/src/qr/qr.module.ts @@ -6,10 +6,10 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Qr } from './qr.entity'; import { AuthModule } from '../auth/auth.module'; import { ConfigModule } from '@nestjs/config'; -import { ParticipanteEvento } from 'src/participante_evento/entities/participante_evento.entity'; +import { Asistencia } from 'src/asistencia/entities/asistencia.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Qr]), AuthModule, ConfigModule, ParticipanteEvento], + imports: [TypeOrmModule.forFeature([Qr]), AuthModule, ConfigModule, Asistencia], controllers: [QrController], providers: [QrService, QrTokenService], exports: [QrTokenService], From 960163fa4aced456b8df3edf64665b045f30b87e Mon Sep 17 00:00:00 2001 From: santiago Date: Thu, 25 Sep 2025 20:57:56 +0200 Subject: [PATCH 27/62] Coreecion de error --- src/qr/qr.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qr/qr.module.ts b/src/qr/qr.module.ts index 4865dd4..d1eb140 100644 --- a/src/qr/qr.module.ts +++ b/src/qr/qr.module.ts @@ -9,7 +9,7 @@ import { ConfigModule } from '@nestjs/config'; import { Asistencia } from 'src/asistencia/entities/asistencia.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Qr]), AuthModule, ConfigModule, Asistencia], + imports: [TypeOrmModule.forFeature([Qr, Asistencia]), AuthModule, ConfigModule], controllers: [QrController], providers: [QrService, QrTokenService], exports: [QrTokenService], From a876c92dcfe148e928c2260f3d07f79d7f4568d6 Mon Sep 17 00:00:00 2001 From: santiago Date: Mon, 29 Sep 2025 12:14:25 -0600 Subject: [PATCH 28/62] Correccion en la validacion del participante evento --- .../participante_evento.service.ts | 6 +++++- src/qr/qr-token.service.ts | 17 ----------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/participante_evento/participante_evento.service.ts b/src/participante_evento/participante_evento.service.ts index 99a1dcb..4dcafeb 100644 --- a/src/participante_evento/participante_evento.service.ts +++ b/src/participante_evento/participante_evento.service.ts @@ -229,8 +229,10 @@ export class ParticipanteEventoService { // Verificar si ya había registrado asistencia if (participanteEvento.asistio && participanteEvento.fecha_asistencia) { + //Borarr + console.log("Usuario se registro exitosamente"); return { - success: true, + success: false, message: 'El participante ya había registrado su asistencia previamente', data: { @@ -248,6 +250,8 @@ export class ParticipanteEventoService { const resultado = await this.participanteEventoRepository.save(participanteEvento); + //Borrar + console.log("El usaurio se registro exitosamente"); return { success: true, message: 'Asistencia registrada exitosamente', diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index 1317541..39d5309 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -67,23 +67,6 @@ export class QrTokenService { const decoded = this.jwtService.verify(token, { secret }); - let asistenciaEvento = await this.asistenciaRepository.findOne({ - where: { - id_participante: decoded.id_participante, - id_evento: decoded.id_evento, - } - }) - if(!asistenciaEvento){ - let newAsistencia=this.asistenciaRepository.create({estado:false,id_evento:decoded.id_evento, id_participante:decoded.id_participante}) - asistenciaEvento= await this.asistenciaRepository.save(newAsistencia) - } - - if (!asistenciaEvento?.estado) { - asistenciaEvento.estado = true - }else{ - throw new UnauthorizedException("El usuario ya se registró"); - } - // Verificar que sea un token de tipo QR de asistencia if (decoded.type !== 'qr_asistencia') { return null; From bbdeab629d5c968c3890eddec9fc32e461a57d15 Mon Sep 17 00:00:00 2001 From: evenegas Date: Tue, 30 Sep 2025 18:45:23 -0600 Subject: [PATCH 29/62] =?UTF-8?q?Se=20agreg=C3=B3=20carga=20masiva?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 1352 +++++++++++++---- package.json | 4 +- src/cuestionario/cuestionario.controller.ts | 162 +- src/evento/dto/create-evento.dto.ts | 4 + .../participante_evento.service.ts | 5 +- 5 files changed, 1261 insertions(+), 266 deletions(-) diff --git a/package-lock.json b/package-lock.json index bb96032..2d10ba1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,14 +27,14 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.7", + "exceljs": "^4.4.0", "mysql2": "^3.14.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "typeorm": "^0.3.21", - "xlsx": "^0.18.5" + "typeorm": "^0.3.21" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -771,9 +771,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.5.1.tgz", - "integrity": "sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, "license": "MIT", "dependencies": { @@ -813,9 +813,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", - "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -828,9 +828,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.1.0.tgz", - "integrity": "sha512-kLrdPDJE1ckPo94kmPPf9Hfd0DU0Jw6oKYrhe+pwSC0iTUInmTa+w6fw8sGgcfkFJGNdWOUeOaDM4quW4a7OkA==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -838,9 +838,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.12.0.tgz", - "integrity": "sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -851,9 +851,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.0.tgz", - "integrity": "sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", "dev": true, "license": "MIT", "dependencies": { @@ -888,13 +888,16 @@ } }, "node_modules/@eslint/js": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.22.0.tgz", - "integrity": "sha512-vLFajx9o8d1/oL2ZkpMYbkLv8nDB6yaIwFNt7nI4+I80U/z03SxmfOMsLbvWr3p7C+Wnoh//aOu2pQW8cS0HCQ==", + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", + "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { @@ -908,19 +911,60 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.7.tgz", - "integrity": "sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.12.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -987,6 +1031,16 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.0.tgz", + "integrity": "sha512-JWaTfCxI1eTmJ1BIv86vUfjVatOdxwD0DAVKYevY8SazeUUZtW+tNbsdejVO1GYE0GXJW1N1ahmiC3TFd+7wZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@inquirer/checkbox": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.4.tgz", @@ -1035,15 +1089,15 @@ } }, "node_modules/@inquirer/core": { - "version": "10.1.9", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.9.tgz", - "integrity": "sha512-sXhVB8n20NYkUBfDYgizGHlpRVaCRjtuzNZA6xpALIUbkgfd2Hjz+DfEN6+h1BRnuxw0/P4jCIMjMsEOAMwAJw==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.2.2.tgz", + "integrity": "sha512-yXq/4QUnk4sHMtmbd7irwiepjB8jXU0kkFRL4nr/aDBA2mDz13cMakEWdDwX3eSCTkk03kwcndD1zfRAIlELxA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/figures": "^1.0.11", - "@inquirer/type": "^3.0.5", - "ansi-escapes": "^4.3.2", + "@inquirer/ansi": "^1.0.0", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", @@ -1063,15 +1117,15 @@ } }, "node_modules/@inquirer/editor": { - "version": "4.2.9", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.9.tgz", - "integrity": "sha512-8HjOppAxO7O4wV1ETUlJFg6NDjp/W2NP5FB9ZPAcinAlNT4ZIWOLe2pUVwmmPRSV0NMdI5r/+lflN55AwZOKSw==", + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.20.tgz", + "integrity": "sha512-7omh5y5bK672Q+Brk4HBbnHNowOZwrb/78IFXdrEB9PfdxL3GudQyDk8O9vQ188wj3xrEebS2M9n18BjJoI83g==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.9", - "@inquirer/type": "^3.0.5", - "external-editor": "^3.1.0" + "@inquirer/core": "^10.2.2", + "@inquirer/external-editor": "^1.0.2", + "@inquirer/type": "^3.0.8" }, "engines": { "node": ">=18" @@ -1108,10 +1162,49 @@ } } }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.2.tgz", + "integrity": "sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.0", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@inquirer/figures": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.11.tgz", - "integrity": "sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", + "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==", "dev": true, "license": "MIT", "engines": { @@ -1288,9 +1381,9 @@ } }, "node_modules/@inquirer/type": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.5.tgz", - "integrity": "sha512-ZJpeIYYueOz/i/ONzrfof8g89kNdO2hjGuvULROo3O8rlB2CRtSseE5KeirnyE4t/thAn/EwvS/vuQeJCn+NZg==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz", + "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==", "dev": true, "license": "MIT", "engines": { @@ -2296,12 +2389,14 @@ } }, "node_modules/@nestjs/common": { - "version": "11.0.12", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.0.12.tgz", - "integrity": "sha512-6PXxmDe2iYmb57xacnxzpW1NAxRZ7Gf+acMT7/hmRB/4KpZiFU/cNvLWwgbM2BL5QSzQulOwY6ny5bbKnPpB+A==", + "version": "11.1.6", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.6.tgz", + "integrity": "sha512-krKwLLcFmeuKDqngG2N/RuZHCs2ycsKcxWIDgcm7i1lf3sQ0iG03ci+DsP/r3FcT/eJDFsIHnKtNta2LIi7PzQ==", "license": "MIT", "dependencies": { + "file-type": "21.0.0", "iterare": "1.2.1", + "load-esm": "1.0.2", "tslib": "2.8.1", "uid": "2.0.2" }, @@ -2310,8 +2405,8 @@ "url": "https://opencollective.com/nest" }, "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, @@ -2324,6 +2419,40 @@ } } }, + "node_modules/@nestjs/common/node_modules/file-type": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.0.0.tgz", + "integrity": "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.2.7", + "strtok3": "^10.2.2", + "token-types": "^6.0.0", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/@nestjs/common/node_modules/strtok3": { + "version": "10.3.4", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", + "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@nestjs/config": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.2.tgz", @@ -2643,6 +2772,19 @@ "typeorm": "^0.3.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2697,6 +2839,16 @@ "npm": ">=5.10.0" } }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2816,9 +2968,9 @@ } }, "node_modules/@swc/cli/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3090,11 +3242,28 @@ "node": ">=14.16" } }, + "node_modules/@tokenizer/inflate": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", + "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fflate": "^0.8.2", + "token-types": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@tokenizer/token": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", - "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node10": { @@ -3637,9 +3806,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4051,9 +4220,9 @@ } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, "license": "MIT", "bin": { @@ -4086,15 +4255,6 @@ "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", @@ -4289,6 +4449,96 @@ ], "license": "MIT" }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -4320,7 +4570,6 @@ "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, "license": "MIT" }, "node_modules/asynckit": { @@ -4329,6 +4578,21 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -4339,13 +4603,13 @@ } }, "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -4525,6 +4789,15 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/bin-version": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bin-version/-/bin-version-6.0.0.tgz", @@ -4560,6 +4833,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -4577,7 +4863,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -4589,7 +4874,6 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -4600,6 +4884,12 @@ "node": ">= 6" } }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.1.0.tgz", @@ -4636,10 +4926,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -4719,7 +5008,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -4744,7 +5032,6 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -4762,6 +5049,23 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -4811,6 +5115,24 @@ "node": ">=14.16" } }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4880,17 +5202,16 @@ ], "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", + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", "dependencies": { - "adler-32": "~1.3.0", - "crc-32": "~1.2.0" + "traverse": ">=0.3.0 <0.4" }, "engines": { - "node": ">=0.8" + "node": "*" } }, "node_modules/chalk": { @@ -4921,9 +5242,9 @@ } }, "node_modules/chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", + "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", "dev": true, "license": "MIT" }, @@ -5118,15 +5439,6 @@ "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", @@ -5201,11 +5513,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -5344,6 +5684,33 @@ "node": ">=0.8" } }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -5503,6 +5870,23 @@ "node": ">=10" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -5628,6 +6012,15 @@ "node": ">= 0.4" } }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5700,6 +6093,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", @@ -5805,20 +6207,20 @@ } }, "node_modules/eslint": { - "version": "9.22.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.22.0.tgz", - "integrity": "sha512-9V/QURhsRN40xuHXWjV64yvrzMjcz7ZyNoF2jJFmy9j/SLk0u1OLSZgXi28MrXjymnjEGSR80WCdab3RGMDveQ==", + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", + "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.2", - "@eslint/config-helpers": "^0.1.0", - "@eslint/core": "^0.12.0", - "@eslint/eslintrc": "^3.3.0", - "@eslint/js": "9.22.0", - "@eslint/plugin-kit": "^0.2.7", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.36.0", + "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -5829,9 +6231,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -5910,9 +6312,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -5927,9 +6329,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5940,15 +6342,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6036,6 +6438,49 @@ "node": ">=0.8.x" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/exceljs/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -6198,32 +6643,17 @@ "node": ">=4" } }, - "node_modules/external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", "license": "MIT", "dependencies": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" }, "engines": { - "node": ">=4" - } - }, - "node_modules/external-editor/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" + "node": ">=10.0.0" } }, "node_modules/fast-deep-equal": { @@ -6334,6 +6764,12 @@ "bser": "2.1.1" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6407,9 +6843,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6562,6 +6998,21 @@ } } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -6672,14 +7123,15 @@ } }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -6718,16 +7170,19 @@ } }, "node_modules/formidable": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.2.tgz", - "integrity": "sha512-Jqc1btCy3QzRbJaICGwKcBfGWuLADRerLzDqi2NwSt/UkXLsHJw2TVResiaoBufHVHy9aSgClOHCeJsSsFLTbg==", + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", "dev": true, "license": "MIT", "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", - "hexoid": "^2.0.0", "once": "^1.4.0" }, + "engines": { + "node": ">=14.0.0" + }, "funding": { "url": "https://ko-fi.com/tunnckoCore/commissions" } @@ -6741,15 +7196,6 @@ "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", @@ -6759,6 +7205,12 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -6785,7 +7237,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -6803,6 +7254,22 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -6945,9 +7412,9 @@ "license": "BSD-2-Clause" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7025,7 +7492,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -7055,6 +7521,18 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -7094,16 +7572,6 @@ "node": ">= 0.4" } }, - "node_modules/hexoid": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-2.0.0.tgz", - "integrity": "sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -7200,6 +7668,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -7252,7 +7726,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -7304,6 +7777,18 @@ "node": ">=8" } }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -7417,6 +7902,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -8343,6 +8843,18 @@ "npm": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/jwa": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", @@ -8394,6 +8906,18 @@ "node": ">=6" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -8424,6 +8948,15 @@ "integrity": "sha512-PJiS4ETaUfCOFLpmtKzAbqZQjCCKVu2OhTV4SVNNE7c2nu/dACvtCqj4L0i/KWNnIgRv7yrILvBj5Lonv5Ncxw==", "license": "MIT" }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -8431,6 +8964,31 @@ "dev": true, "license": "MIT" }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/load-esm": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.2.tgz", + "integrity": "sha512-nVAvWk/jeyrWyXEAs84mpQCYccxRqgKY4OznLuJhJCa0XsPSfdOIr2zvBZEj3IHEHbX97jjscKRRV539bW0Gpw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -8463,6 +9021,36 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -8475,12 +9063,31 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", "license": "MIT" }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", @@ -8499,6 +9106,12 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -8519,6 +9132,18 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -8780,7 +9405,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9007,7 +9631,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9162,16 +9785,6 @@ "node": ">=8" } }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/p-cancelable": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", @@ -9229,6 +9842,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9319,7 +9938,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9541,6 +10159,15 @@ "node": ">=10.13.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -9939,6 +10566,36 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -10113,6 +10770,40 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/router": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/router/-/router-2.1.0.tgz", @@ -10186,6 +10877,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/schema-utils": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", @@ -10359,6 +11062,29 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -10366,16 +11092,23 @@ "license": "ISC" }, "node_modules/sha.js": { - "version": "2.4.11", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", - "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", "license": "(MIT AND BSD-3-Clause)", "dependencies": { - "inherits": "^2.0.1", - "safe-buffer": "^5.0.1" + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" }, "bin": { "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/shebang-command": { @@ -10589,18 +11322,6 @@ "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", @@ -11218,16 +11939,12 @@ "license": "MIT" }, "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "license": "MIT", - "dependencies": { - "os-tmpdir": "~1.0.2" - }, "engines": { - "node": ">=0.6.0" + "node": ">=14.14" } }, "node_modules/tmpl": { @@ -11237,6 +11954,26 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-buffer/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -11263,7 +12000,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.0.0.tgz", "integrity": "sha512-lbDrTLVsHhOMljPscd0yitpozq7Ga2M5Cvez5AjGg8GASBjtt6iERCAJ93yommPmz62fb45oFIXHEZ3u9bfJEA==", - "dev": true, "license": "MIT", "dependencies": { "@tokenizer/token": "^0.3.0", @@ -11277,6 +12013,15 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -11511,6 +12256,20 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -11623,9 +12382,9 @@ } }, "node_modules/typeorm/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -11780,7 +12539,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz", "integrity": "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -11825,6 +12583,24 @@ "node": ">= 0.8" } }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -12191,22 +12967,25 @@ "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", + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, "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": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/word-wrap": { @@ -12320,26 +13099,11 @@ "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/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" }, "node_modules/xtend": { "version": "4.0.2", @@ -12442,6 +13206,76 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } } } } diff --git a/package.json b/package.json index f56500c..25f9230 100644 --- a/package.json +++ b/package.json @@ -38,14 +38,14 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.7", + "exceljs": "^4.4.0", "mysql2": "^3.14.0", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "typeorm": "^0.3.21", - "xlsx": "^0.18.5" + "typeorm": "^0.3.21" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/src/cuestionario/cuestionario.controller.ts b/src/cuestionario/cuestionario.controller.ts index a4352b4..5654e23 100644 --- a/src/cuestionario/cuestionario.controller.ts +++ b/src/cuestionario/cuestionario.controller.ts @@ -9,15 +9,20 @@ import { UseInterceptors, ParseIntPipe, UploadedFile, + UnprocessableEntityException, + UploadedFiles, } from '@nestjs/common'; import { CuestionarioService } from './cuestionario.service'; import { CreateCuestionarioDto } from './dto/create-cuestionario.dto'; import { UpdateCuestionarioDto } from './dto/update-cuestionario.dto'; -import { FileInterceptor } from '@nestjs/platform-express'; +import { FileInterceptor, FilesInterceptor } 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'; +import * as ExcelJS from 'exceljs'; +import { TipoPreguntaEnum } from 'src/tipo_pregunta/entities/tipo_pregunta.entity'; +import { TiposValidacion } from 'src/pregunta/entities/pregunta.entity'; @Controller('cuestionario') @CuestionarioApiDocumentation.ApiController @@ -30,6 +35,158 @@ export class CuestionarioController { return this.cuestionarioService.create(createCuestionarioDto); } + + private mapTipoPregunta(tipo: string): TipoPreguntaEnum { + switch (tipo.toLowerCase()) { + case 'cerrada': return TipoPreguntaEnum.Cerrada; + case 'multiple': return TipoPreguntaEnum.Multiple; + case 'abierta (parrafo)': return TipoPreguntaEnum.AbiertaParrafo; + case 'abierta (respuesta corta)': return TipoPreguntaEnum.AbiertaRespuestaCorta; + default: throw new Error(`Tipo de pregunta inválido: ${tipo}`); + } + } + + @Post('banners/bulk') + @UseInterceptors( + FilesInterceptor('banners', 20, { // puedes subir hasta 20 imágenes + storage: diskStorage({ + destination: './uploads/banners', + filename: (req, file, cb) => { + cb(null, file.originalname); + + + }, + }), + 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 subirBanners(@UploadedFiles() files: Express.Multer.File[]) { + if (!files || files.length === 0) { + throw new UnprocessableEntityException('No se subió ningún archivo'); + } + + // Retornamos lista de filenames para asociarlos después + return files.map((file) => ({ + originalName: file.originalname, + filename: file.filename, + path: file.path, + })); + } + + // Mapear validación desde string a enum + private mapValidacion(validacion?: string): TiposValidacion | undefined { + if (!validacion) return undefined; + + switch (validacion.trim().toLowerCase()) { + case 'correo': return TiposValidacion.CORREO; + case 'correo_institucional': return TiposValidacion.CORREO_INSTITUCIONAL; + case 'telefono': return TiposValidacion.TELEFONO; + case 'nombre': return TiposValidacion.NOMBRE; + case 'apellidos': return TiposValidacion.APELLIDOS; + case 'entero': return TiposValidacion.ENTERO; + case 'decimal': return TiposValidacion.DECIMAL; + case 'comunidad_alumno': return TiposValidacion.COMUNIDAD_ALUMNO; + case 'cuenta_alumno': return TiposValidacion.CUENTA_ALUMNO; + case 'comunidad_trabajador': return TiposValidacion.COMUNIDAD_TRABAJADOR; + case 'cuenta_trabajador': return TiposValidacion.CUENTA_TRABAJADOR; + case 'genero': return TiposValidacion.GENERO; + case 'carrera': return TiposValidacion.CARRERA; + case 'institucion': return TiposValidacion.INSTITUCION; + case 'rfc': return TiposValidacion.RFC; + default: return undefined; + } + } + + // Función principal de carga masiva + async cargarEventosDesdeExcel(filePath: string) { + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.readFile(filePath); + + const eventosSheet = workbook.getWorksheet('Eventos'); + const cuestionariosSheet = workbook.getWorksheet('Cuestionarios'); + const seccionesSheet = workbook.getWorksheet('Secciones'); + const preguntasSheet = workbook.getWorksheet('Preguntas'); + const opcionesSheet = workbook.getWorksheet('Opciones'); + + if (!eventosSheet || !cuestionariosSheet || !seccionesSheet || !preguntasSheet || !opcionesSheet) { + throw new UnprocessableEntityException('El archivo Excel debe contener las hojas: Eventos, Cuestionarios, Secciones, Preguntas y Opciones.'); + } + + for (let i = 2; i <= eventosSheet.rowCount; i++) { + const row = eventosSheet.getRow(i); + + // Construir objeto evento + const eventoDto = { + tipo_evento: row.getCell(2).value as string, + nombre_evento: row.getCell(3).value as string, + descripcion_evento: row.getCell(4).value as string, + fecha_inicio: new Date(row.getCell(5).value as string), + fecha_fin: new Date(row.getCell(6).value as string), + }; + + // Filtrar cuestionarios de este evento + const cuestionarios = cuestionariosSheet.getRows(2, cuestionariosSheet.rowCount - 1) + ?.filter(qRow => qRow.getCell(2).value === row.getCell(1).value) || []; + + for (const qRow of cuestionarios) { + // Filtrar secciones de este cuestionario + const secciones = seccionesSheet.getRows(2, seccionesSheet.rowCount - 1) + ?.filter(sRow => sRow.getCell(2).value === qRow.getCell(1).value) + .map(sRow => { + // Filtrar preguntas de esta sección + const preguntas = preguntasSheet.getRows(2, preguntasSheet.rowCount - 1) + ?.filter(pRow => pRow.getCell(2).value === sRow.getCell(1).value) + .map(pRow => { + const opciones = opcionesSheet.getRows(2, opcionesSheet.rowCount - 1) + ?.filter(oRow => oRow.getCell(2).value === pRow.getCell(1).value) + .map(oRow => ({ valor: oRow.getCell(3).value as string })) || []; + + return { + titulo: pRow.getCell(3).value as string, + tipo: this.mapTipoPregunta(pRow.getCell(4).value as string), + obligatoria: !!pRow.getCell(5).value, + validacion: this.mapValidacion(pRow.getCell(6).value as string), + opciones, + }; + }) || []; + + return { + titulo: sRow.getCell(3).value as string, + descripcion: sRow.getCell(4).value as string, + preguntas, + }; + }) || []; + + // Construir DTO completo + const createDto: CreateEventoWithCuestionarioDto = { + evento: eventoDto, + cuestionario: { + nombre_form: qRow.getCell(3).value as string, + descripcion: qRow.getCell(4).value as string, + fecha_inicio: new Date(qRow.getCell(5).value as string), + fecha_fin: new Date(qRow.getCell(6).value as string), + id_tipo_cuestionario: Number(qRow.getCell(7).value), + id_tipo_evento: Number(qRow.getCell(8).value), // obligatorio + secciones, + }, + }; + + // Llamada a tu servicio para guardar en DB + await this.cuestionarioService.createCuestionarioEvento(createDto); + } + } + + return { message: 'Carga masiva completada' }; + } + @Post('withEvento') @CuestionarioApiDocumentation.ApiCreateWithEvento createCuestionarioEvento( @@ -40,6 +197,9 @@ export class CuestionarioController { ); } + + + @Post(':id/banner') @UseInterceptors( FileInterceptor('banner', { diff --git a/src/evento/dto/create-evento.dto.ts b/src/evento/dto/create-evento.dto.ts index b5e6986..cea452d 100644 --- a/src/evento/dto/create-evento.dto.ts +++ b/src/evento/dto/create-evento.dto.ts @@ -23,4 +23,8 @@ export class CreateEventoDto { @IsDate({ message: 'La fecha de fin debe ser una fecha válida.' }) @IsNotEmpty({ message: 'La fecha de fin es obligatoria.' }) fecha_fin: Date; + + @IsOptional() + @IsString() + banner?: string; } diff --git a/src/participante_evento/participante_evento.service.ts b/src/participante_evento/participante_evento.service.ts index 4dcafeb..3625e16 100644 --- a/src/participante_evento/participante_evento.service.ts +++ b/src/participante_evento/participante_evento.service.ts @@ -229,8 +229,6 @@ export class ParticipanteEventoService { // Verificar si ya había registrado asistencia if (participanteEvento.asistio && participanteEvento.fecha_asistencia) { - //Borarr - console.log("Usuario se registro exitosamente"); return { success: false, message: @@ -250,8 +248,7 @@ export class ParticipanteEventoService { const resultado = await this.participanteEventoRepository.save(participanteEvento); - //Borrar - console.log("El usaurio se registro exitosamente"); + return { success: true, message: 'Asistencia registrada exitosamente', From 919d94cb0e27f76597f751512fe65eafbcce506b Mon Sep 17 00:00:00 2001 From: evenegas Date: Wed, 1 Oct 2025 10:14:27 -0600 Subject: [PATCH 30/62] Se creo un evento por xlsx --- src/cuestionario/cuestionario.controller.ts | 127 ++------------- src/cuestionario/cuestionario.service.ts | 152 +++++++++++++++++- src/cuestionario/plantillas.ts | 123 ++++++++++++++ .../cuestionario_respondido.service.ts | 6 +- src/qr/qr-token.service.ts | 20 ++- src/qr/qr.controller.ts | 2 +- src/qr/qr.module.ts | 3 +- 7 files changed, 312 insertions(+), 121 deletions(-) create mode 100644 src/cuestionario/plantillas.ts diff --git a/src/cuestionario/cuestionario.controller.ts b/src/cuestionario/cuestionario.controller.ts index 5654e23..ac2d9f0 100644 --- a/src/cuestionario/cuestionario.controller.ts +++ b/src/cuestionario/cuestionario.controller.ts @@ -11,6 +11,7 @@ import { UploadedFile, UnprocessableEntityException, UploadedFiles, + BadRequestException, } from '@nestjs/common'; import { CuestionarioService } from './cuestionario.service'; import { CreateCuestionarioDto } from './dto/create-cuestionario.dto'; @@ -35,17 +36,6 @@ export class CuestionarioController { return this.cuestionarioService.create(createCuestionarioDto); } - - private mapTipoPregunta(tipo: string): TipoPreguntaEnum { - switch (tipo.toLowerCase()) { - case 'cerrada': return TipoPreguntaEnum.Cerrada; - case 'multiple': return TipoPreguntaEnum.Multiple; - case 'abierta (parrafo)': return TipoPreguntaEnum.AbiertaParrafo; - case 'abierta (respuesta corta)': return TipoPreguntaEnum.AbiertaRespuestaCorta; - default: throw new Error(`Tipo de pregunta inválido: ${tipo}`); - } - } - @Post('banners/bulk') @UseInterceptors( FilesInterceptor('banners', 20, { // puedes subir hasta 20 imágenes @@ -67,8 +57,7 @@ export class CuestionarioController { } }, }), - ) - async subirBanners(@UploadedFiles() files: Express.Multer.File[]) { + )async subirBanners(@UploadedFiles() files: Express.Multer.File[]) { if (!files || files.length === 0) { throw new UnprocessableEntityException('No se subió ningún archivo'); } @@ -81,111 +70,23 @@ export class CuestionarioController { })); } - // Mapear validación desde string a enum - private mapValidacion(validacion?: string): TiposValidacion | undefined { - if (!validacion) return undefined; - - switch (validacion.trim().toLowerCase()) { - case 'correo': return TiposValidacion.CORREO; - case 'correo_institucional': return TiposValidacion.CORREO_INSTITUCIONAL; - case 'telefono': return TiposValidacion.TELEFONO; - case 'nombre': return TiposValidacion.NOMBRE; - case 'apellidos': return TiposValidacion.APELLIDOS; - case 'entero': return TiposValidacion.ENTERO; - case 'decimal': return TiposValidacion.DECIMAL; - case 'comunidad_alumno': return TiposValidacion.COMUNIDAD_ALUMNO; - case 'cuenta_alumno': return TiposValidacion.CUENTA_ALUMNO; - case 'comunidad_trabajador': return TiposValidacion.COMUNIDAD_TRABAJADOR; - case 'cuenta_trabajador': return TiposValidacion.CUENTA_TRABAJADOR; - case 'genero': return TiposValidacion.GENERO; - case 'carrera': return TiposValidacion.CARRERA; - case 'institucion': return TiposValidacion.INSTITUCION; - case 'rfc': return TiposValidacion.RFC; - default: return undefined; - } + @Post('carga-masiva') +@UseInterceptors(FileInterceptor('file')) +async cargaMasiva(@UploadedFile() file: Express.Multer.File) { + if (!file) { + throw new BadRequestException('No se ha subido ningún archivo'); } - // Función principal de carga masiva - async cargarEventosDesdeExcel(filePath: string) { - const workbook = new ExcelJS.Workbook(); - await workbook.xlsx.readFile(filePath); + const cuestionarios = await this.cargaMasiva(file); - const eventosSheet = workbook.getWorksheet('Eventos'); - const cuestionariosSheet = workbook.getWorksheet('Cuestionarios'); - const seccionesSheet = workbook.getWorksheet('Secciones'); - const preguntasSheet = workbook.getWorksheet('Preguntas'); - const opcionesSheet = workbook.getWorksheet('Opciones'); + return { + message: 'Carga masiva exitosa', + total: cuestionarios.length, + data: cuestionarios, + }; +} - if (!eventosSheet || !cuestionariosSheet || !seccionesSheet || !preguntasSheet || !opcionesSheet) { - throw new UnprocessableEntityException('El archivo Excel debe contener las hojas: Eventos, Cuestionarios, Secciones, Preguntas y Opciones.'); - } - for (let i = 2; i <= eventosSheet.rowCount; i++) { - const row = eventosSheet.getRow(i); - - // Construir objeto evento - const eventoDto = { - tipo_evento: row.getCell(2).value as string, - nombre_evento: row.getCell(3).value as string, - descripcion_evento: row.getCell(4).value as string, - fecha_inicio: new Date(row.getCell(5).value as string), - fecha_fin: new Date(row.getCell(6).value as string), - }; - - // Filtrar cuestionarios de este evento - const cuestionarios = cuestionariosSheet.getRows(2, cuestionariosSheet.rowCount - 1) - ?.filter(qRow => qRow.getCell(2).value === row.getCell(1).value) || []; - - for (const qRow of cuestionarios) { - // Filtrar secciones de este cuestionario - const secciones = seccionesSheet.getRows(2, seccionesSheet.rowCount - 1) - ?.filter(sRow => sRow.getCell(2).value === qRow.getCell(1).value) - .map(sRow => { - // Filtrar preguntas de esta sección - const preguntas = preguntasSheet.getRows(2, preguntasSheet.rowCount - 1) - ?.filter(pRow => pRow.getCell(2).value === sRow.getCell(1).value) - .map(pRow => { - const opciones = opcionesSheet.getRows(2, opcionesSheet.rowCount - 1) - ?.filter(oRow => oRow.getCell(2).value === pRow.getCell(1).value) - .map(oRow => ({ valor: oRow.getCell(3).value as string })) || []; - - return { - titulo: pRow.getCell(3).value as string, - tipo: this.mapTipoPregunta(pRow.getCell(4).value as string), - obligatoria: !!pRow.getCell(5).value, - validacion: this.mapValidacion(pRow.getCell(6).value as string), - opciones, - }; - }) || []; - - return { - titulo: sRow.getCell(3).value as string, - descripcion: sRow.getCell(4).value as string, - preguntas, - }; - }) || []; - - // Construir DTO completo - const createDto: CreateEventoWithCuestionarioDto = { - evento: eventoDto, - cuestionario: { - nombre_form: qRow.getCell(3).value as string, - descripcion: qRow.getCell(4).value as string, - fecha_inicio: new Date(qRow.getCell(5).value as string), - fecha_fin: new Date(qRow.getCell(6).value as string), - id_tipo_cuestionario: Number(qRow.getCell(7).value), - id_tipo_evento: Number(qRow.getCell(8).value), // obligatorio - secciones, - }, - }; - - // Llamada a tu servicio para guardar en DB - await this.cuestionarioService.createCuestionarioEvento(createDto); - } - } - - return { message: 'Carga masiva completada' }; - } @Post('withEvento') @CuestionarioApiDocumentation.ApiCreateWithEvento diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index a4ff6c8..fe01f30 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -2,6 +2,7 @@ import { Injectable, InternalServerErrorException, NotFoundException, + UnprocessableEntityException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, In, MoreThan } from 'typeorm'; @@ -13,7 +14,7 @@ import { UpdateCuestionarioDto } from './dto/update-cuestionario.dto'; import { Cuestionario } from './entities/cuestionario.entity'; import { Seccion } from '../seccion/entities/seccion.entity'; import { CuestionarioSeccion } from '../cuestionario_seccion/entities/cuestionario_seccion.entity'; -import { Pregunta } from '../pregunta/entities/pregunta.entity'; +import { Pregunta, TiposValidacion } 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'; @@ -24,6 +25,8 @@ import { import { Evento } from '../evento/entities/evento.entity'; import { EventoService } from '../evento/evento.service'; import { CreateEventoWithCuestionarioDto } from './dto/create-evento-cuestionario.dto'; +import * as ExcelJS from 'exceljs'; +import { PLANTILLAS } from './plantillas'; @Injectable() export class CuestionarioService { @@ -50,6 +53,153 @@ export class CuestionarioService { private eventoService: EventoService, ) {} + + + private mapTipoPregunta(tipo: string): TipoPreguntaEnum { + switch (tipo.toLowerCase()) { + case 'cerrada': return TipoPreguntaEnum.Cerrada; + case 'multiple': return TipoPreguntaEnum.Multiple; + case 'abierta (parrafo)': return TipoPreguntaEnum.AbiertaParrafo; + case 'abierta (respuesta corta)': return TipoPreguntaEnum.AbiertaRespuestaCorta; + default: throw new Error(`Tipo de pregunta inválido: ${tipo}`); + } + } + + // Mapear validación desde string a enum + private mapValidacion(validacion?: string): TiposValidacion | undefined { + if (!validacion) return undefined; + + switch (validacion.trim().toLowerCase()) { + case 'correo': return TiposValidacion.CORREO; + case 'correo_institucional': return TiposValidacion.CORREO_INSTITUCIONAL; + case 'telefono': return TiposValidacion.TELEFONO; + case 'nombre': return TiposValidacion.NOMBRE; + case 'apellidos': return TiposValidacion.APELLIDOS; + case 'entero': return TiposValidacion.ENTERO; + case 'decimal': return TiposValidacion.DECIMAL; + case 'comunidad_alumno': return TiposValidacion.COMUNIDAD_ALUMNO; + case 'cuenta_alumno': return TiposValidacion.CUENTA_ALUMNO; + case 'comunidad_trabajador': return TiposValidacion.COMUNIDAD_TRABAJADOR; + case 'cuenta_trabajador': return TiposValidacion.CUENTA_TRABAJADOR; + case 'genero': return TiposValidacion.GENERO; + case 'carrera': return TiposValidacion.CARRERA; + case 'institucion': return TiposValidacion.INSTITUCION; + case 'rfc': return TiposValidacion.RFC; + default: return undefined; + } + } + + + async cargarEventosDesdeExcel(filePath: string) { + const PLANTILLA_MAP: Record = { + 1: 'registro-comunidad-alumnos', + 2: 'registro-comunidad-trabajadores', + 3: 'registro-general', + }; + + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.readFile(filePath); + + const eventosSheet = workbook.getWorksheet('Eventos'); + const cuestionariosSheet = workbook.getWorksheet('Cuestionarios'); + const seccionesSheet = workbook.getWorksheet('Secciones'); + const preguntasSheet = workbook.getWorksheet('Preguntas'); + const opcionesSheet = workbook.getWorksheet('Opciones'); + + if (!eventosSheet || !cuestionariosSheet || !seccionesSheet || !preguntasSheet || !opcionesSheet) { + throw new UnprocessableEntityException('El archivo Excel debe contener las hojas: Eventos, Cuestionarios, Secciones, Preguntas y Opciones.'); + } + + for (let i = 2; i <= eventosSheet.rowCount; i++) { + const row = eventosSheet.getRow(i); + + // Construir objeto evento + const eventoDto = { + tipo_evento: row.getCell(2).value as string, + nombre_evento: row.getCell(3).value as string, + descripcion_evento: row.getCell(4).value as string, + fecha_inicio: new Date(row.getCell(5).value as string), + fecha_fin: new Date(row.getCell(6).value as string), + banner: row.getCell(7).value as string, // nombre del archivo subido previamente + }; + + // Filtrar cuestionarios de este evento + const cuestionarios = cuestionariosSheet.getRows(2, cuestionariosSheet.rowCount - 1) + ?.filter(qRow => qRow.getCell(2).value === row.getCell(1).value) || []; + + for (const qRow of cuestionarios) { + + const plantilla = qRow.getCell(9).value ? Number(row.getCell(8).value) : null; + + + if (plantilla && PLANTILLA_MAP[plantilla]) { + const fecha_inicio = new Date(qRow.getCell(4).value as string); + const fecha_fin = new Date(qRow.getCell(5).value as string); + const id_tipo_evento= Number(qRow.getCell(8).value); + const plantillaId = PLANTILLA_MAP[plantilla]; + const base = PLANTILLAS.find(p => p.id === plantillaId); + if (!base) throw new Error(`Plantilla ${plantillaId} no encontrada`); + return { + ...base.datos, + fecha_inicio, + fecha_fin, + id_tipo_evento, + + + }; + } + + + // Filtrar secciones de este cuestionario + const secciones = seccionesSheet.getRows(2, seccionesSheet.rowCount - 1) + ?.filter(sRow => sRow.getCell(2).value === qRow.getCell(1).value) + .map(sRow => { + // Filtrar preguntas de esta sección + const preguntas = preguntasSheet.getRows(2, preguntasSheet.rowCount - 1) + ?.filter(pRow => pRow.getCell(2).value === sRow.getCell(1).value) + .map(pRow => { + const opciones = opcionesSheet.getRows(2, opcionesSheet.rowCount - 1) + ?.filter(oRow => oRow.getCell(2).value === pRow.getCell(1).value) + .map(oRow => ({ valor: oRow.getCell(3).value as string })) || []; + + return { + titulo: pRow.getCell(3).value as string, + tipo: this.mapTipoPregunta(pRow.getCell(4).value as string), + obligatoria: !!pRow.getCell(5).value, + validacion: this.mapValidacion(pRow.getCell(6).value as string), + opciones, + }; + }) || []; + + return { + titulo: sRow.getCell(3).value as string, + descripcion: sRow.getCell(4).value as string, + preguntas, + }; + }) || []; + + // Construir DTO completo + const createDto: CreateEventoWithCuestionarioDto = { + evento: eventoDto, + cuestionario: { + nombre_form: qRow.getCell(3).value as string, + descripcion: qRow.getCell(4).value as string, + fecha_inicio: new Date(qRow.getCell(5).value as string), + fecha_fin: new Date(qRow.getCell(6).value as string), + id_tipo_cuestionario: Number(qRow.getCell(7).value), + id_tipo_evento: Number(qRow.getCell(8).value), // obligatorio + secciones, + }, + }; + + // Llamada a tu servicio para guardar en DB + await this.createCuestionarioEvento(createDto); + } + } + + return { message: 'Carga masiva completada' }; + } + async create(createCuestionarioDto: CreateCuestionarioDto) { // Verificar que el evento exista await this.eventoService.getEventoOrFail(createCuestionarioDto.id_evento); diff --git a/src/cuestionario/plantillas.ts b/src/cuestionario/plantillas.ts new file mode 100644 index 0000000..6a359d9 --- /dev/null +++ b/src/cuestionario/plantillas.ts @@ -0,0 +1,123 @@ +export const PLANTILLAS = [ + { + imagen: '/plantilla_trabajadores.png', + nombre: 'Registro Comunidad Trabajador', + id: 'registro-comunidad-trabajadores', + datos: { + nombre_form: 'Registro', + descripcion: + 'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.', + 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: 'comunidad_trabajador', + }, + { titulo: 'RFC', tipo: 'Abierta (Respuesta corta)', obligatoria: false, validacion: 'rfc' }, + { titulo: 'Correo electrónico', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'correo' }, + { titulo: 'Nombre(s)', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'nombre' }, + { titulo: 'Apellidos', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'apellidos' }, + { + titulo: 'Género', + tipo: 'Cerrada', + obligatoria: true, + validacion: 'genero', + opciones: [ + { valor: 'Masculino' }, + { valor: 'Femenino' }, + { valor: 'Prefiero no decirlo' }, + ], + }, + { titulo: 'Carrera', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'carrera' }, + ], + }, + ], + }, + }, + { + imagen: '/plantilla_alumnos.png', + nombre: 'Registro Comunidad Alumnos', + id: 'registro-comunidad-alumnos', + datos: { + nombre_form: 'Registro', + descripcion: + 'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.', + 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: 'comunidad_alumno', + }, + { titulo: 'Numero de cuenta', tipo: 'Abierta (Respuesta corta)', obligatoria: false, validacion: 'cuenta_alumno' }, + { titulo: 'Correo electrónico', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'correo' }, + { titulo: 'Nombre(s)', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'nombre' }, + { titulo: 'Apellidos', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'apellidos' }, + { + titulo: 'Género', + tipo: 'Cerrada', + obligatoria: true, + validacion: 'genero', + opciones: [ + { valor: 'Masculino' }, + { valor: 'Femenino' }, + { valor: 'Prefiero no decirlo' }, + ], + }, + { titulo: 'Institución de procedencia', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'institucion' }, + { titulo: 'Carrera', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'carrera' }, + ], + }, + ], + }, + }, + { + imagen: '/plantilla_general.png', + nombre: 'Registro General', + id: 'registro-general', + datos: { + nombre_form: 'Registro Para Asistencia General', + descripcion: + 'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.', + 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: 'Número de cuenta / trabajador', tipo: 'Abierta (Respuesta corta)', obligatoria: false }, + { titulo: 'Correo electrónico', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'correo' }, + { titulo: 'Nombre(s)', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'nombre' }, + { titulo: 'Apellidos', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'apellidos' }, + { + titulo: 'Tipo de Asistente', + tipo: 'Cerrada', + obligatoria: true, + opciones: [ + { valor: 'Alumno' }, + { valor: 'Profesor' }, + { valor: 'Trabajador' }, + ], + }, + ], + }, + ], + }, + }, +]; diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index 9f3f426..0de8073 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -112,10 +112,11 @@ export class CuestionarioRespondidoService { if (cuestionario && cuestionario.evento) { try { // Generar token para el QR - qrToken = this.qrTokenService.generateQrToken( + qrToken = await this.qrTokenService.generateQrToken( participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, + ); // Generar código QR con el token @@ -336,10 +337,11 @@ export class CuestionarioRespondidoService { } // Generar token para el QR - qrToken = this.qrTokenService.generateQrToken( + qrToken = await this.qrTokenService.generateQrToken( participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, + ); qrBuffer = await QRCode.toBuffer(qrToken); diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index 39d5309..189b182 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Asistencia } from 'src/asistencia/entities/asistencia.entity'; +import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity'; @Injectable() export class QrTokenService { @@ -12,6 +13,8 @@ export class QrTokenService { private configService: ConfigService, @InjectRepository(Asistencia) private readonly asistenciaRepository: Repository, + @InjectRepository(Cuestionario) + private readonly cuestionarioRepository: Repository, ) {} /** @@ -21,11 +24,11 @@ export class QrTokenService { * @param id_cuestionario ID del cuestionario * @returns Token JWT codificado */ - generateQrToken( + async generateQrToken( id_participante: number, id_evento: number, id_cuestionario: number, - ): string { + ): Promise { const payload = { id_participante, id_evento, @@ -39,9 +42,20 @@ export class QrTokenService { this.configService.get('QR_JWT_SECRET') || this.configService.get('JWT_SECRET', 'tu_clave_secreta'); + const fecha_fin = await this.cuestionarioRepository.findOne({ + where: { id_cuestionario }, + select: ['fecha_fin'], + }); + + if (!fecha_fin) { + throw new UnauthorizedException('Cuestionario no encontrado'); + } + // Convert fecha_fin to seconds from now for expiresIn + const expiresInSeconds = Math.floor((fecha_fin?.fecha_fin.getTime() - Date.now()) / 1000); + return this.jwtService.sign(payload, { secret, - expiresIn: '30d', // El QR puede ser válido por 30 días + expiresIn: expiresInSeconds > 0 ? expiresInSeconds : 0, // El QR puede ser válido por 30 días }); } diff --git a/src/qr/qr.controller.ts b/src/qr/qr.controller.ts index bb6246c..b435d5f 100644 --- a/src/qr/qr.controller.ts +++ b/src/qr/qr.controller.ts @@ -79,7 +79,7 @@ export class QrController { @Param('idEvento', ParseIntPipe) idEvento: number, @Param('idCuestionario', ParseIntPipe) idCuestionario: number, ) { - const token = this.qrTokenService.generateQrToken( + const token = await this.qrTokenService.generateQrToken( idParticipante, idEvento, idCuestionario, diff --git a/src/qr/qr.module.ts b/src/qr/qr.module.ts index d1eb140..9960373 100644 --- a/src/qr/qr.module.ts +++ b/src/qr/qr.module.ts @@ -7,9 +7,10 @@ import { Qr } from './qr.entity'; import { AuthModule } from '../auth/auth.module'; import { ConfigModule } from '@nestjs/config'; import { Asistencia } from 'src/asistencia/entities/asistencia.entity'; +import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Qr, Asistencia]), AuthModule, ConfigModule], + imports: [TypeOrmModule.forFeature([Qr, Asistencia, Cuestionario]), AuthModule, ConfigModule], controllers: [QrController], providers: [QrService, QrTokenService], exports: [QrTokenService], From 01b5c090ecd19b85b33bbc1fb1755446f03631a9 Mon Sep 17 00:00:00 2001 From: santiago Date: Wed, 1 Oct 2025 10:17:19 -0600 Subject: [PATCH 31/62] modificaciones pero sin enviar --- src/qr/qr-token.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index 39d5309..4446794 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -41,7 +41,7 @@ export class QrTokenService { return this.jwtService.sign(payload, { secret, - expiresIn: '30d', // El QR puede ser válido por 30 días + expiresIn: '30d', // 30d El QR puede ser válido por 30 días }); } From 1e09c147671dbd13d0de34d77025a2aeb8a2b67f Mon Sep 17 00:00:00 2001 From: evenegas Date: Wed, 1 Oct 2025 11:24:12 -0600 Subject: [PATCH 32/62] qr --- src/cuestionario/cuestionario.controller.ts | 6 ++---- src/qr/qr-token.service.ts | 12 +++++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/cuestionario/cuestionario.controller.ts b/src/cuestionario/cuestionario.controller.ts index ac2d9f0..e8c3eb5 100644 --- a/src/cuestionario/cuestionario.controller.ts +++ b/src/cuestionario/cuestionario.controller.ts @@ -21,9 +21,7 @@ import { diskStorage } from 'multer'; import { extname } from 'path'; import { CuestionarioApiDocumentation } from './docs/cuestionario.documentation'; import { CreateEventoWithCuestionarioDto } from './dto/create-evento-cuestionario.dto'; -import * as ExcelJS from 'exceljs'; -import { TipoPreguntaEnum } from 'src/tipo_pregunta/entities/tipo_pregunta.entity'; -import { TiposValidacion } from 'src/pregunta/entities/pregunta.entity'; + @Controller('cuestionario') @CuestionarioApiDocumentation.ApiController @@ -70,7 +68,7 @@ export class CuestionarioController { })); } - @Post('carga-masiva') +@Post('carga-masiva') @UseInterceptors(FileInterceptor('file')) async cargaMasiva(@UploadedFile() file: Express.Multer.File) { if (!file) { diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index 189b182..6c8c1f6 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Asistencia } from 'src/asistencia/entities/asistencia.entity'; import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity'; +import { Evento } from 'src/evento/entities/evento.entity'; @Injectable() export class QrTokenService { @@ -15,6 +16,8 @@ export class QrTokenService { private readonly asistenciaRepository: Repository, @InjectRepository(Cuestionario) private readonly cuestionarioRepository: Repository, + @InjectRepository(Evento) + private readonly eventoREpo: Repository, ) {} /** @@ -44,14 +47,17 @@ export class QrTokenService { const fecha_fin = await this.cuestionarioRepository.findOne({ where: { id_cuestionario }, - select: ['fecha_fin'], + }); + const fecha_fin2 = await this.eventoREpo.findOne({ + where: { id_evento }, + }); - if (!fecha_fin) { + if (!fecha_fin2) { throw new UnauthorizedException('Cuestionario no encontrado'); } // Convert fecha_fin to seconds from now for expiresIn - const expiresInSeconds = Math.floor((fecha_fin?.fecha_fin.getTime() - Date.now()) / 1000); + const expiresInSeconds = Math.floor((fecha_fin2?.fecha_fin.getTime() - Date.now()) / 1000); return this.jwtService.sign(payload, { secret, From 8690c8242999db3049e5ae11b290128e773f3c1d Mon Sep 17 00:00:00 2001 From: evenegas Date: Wed, 1 Oct 2025 11:33:17 -0600 Subject: [PATCH 33/62] qr --- src/qr/qr.module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/qr/qr.module.ts b/src/qr/qr.module.ts index 9960373..658d954 100644 --- a/src/qr/qr.module.ts +++ b/src/qr/qr.module.ts @@ -8,9 +8,10 @@ import { AuthModule } from '../auth/auth.module'; import { ConfigModule } from '@nestjs/config'; import { Asistencia } from 'src/asistencia/entities/asistencia.entity'; import { Cuestionario } from 'src/cuestionario/entities/cuestionario.entity'; +import { Evento } from 'src/evento/entities/evento.entity'; @Module({ - imports: [TypeOrmModule.forFeature([Qr, Asistencia, Cuestionario]), AuthModule, ConfigModule], + imports: [TypeOrmModule.forFeature([Qr, Asistencia, Cuestionario, Evento]), AuthModule, ConfigModule], controllers: [QrController], providers: [QrService, QrTokenService], exports: [QrTokenService], From f346da673b469c09486d199d680caf7988b32aa1 Mon Sep 17 00:00:00 2001 From: evenegas Date: Wed, 1 Oct 2025 12:00:14 -0600 Subject: [PATCH 34/62] qr --- .../cuestionario_respondido.service.ts | 2 -- src/qr/qr-token.service.ts | 18 ++++++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index 0de8073..0f8d78a 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -116,7 +116,6 @@ export class CuestionarioRespondidoService { participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, - ); // Generar código QR con el token @@ -341,7 +340,6 @@ export class CuestionarioRespondidoService { participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, - ); qrBuffer = await QRCode.toBuffer(qrToken); diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index 6c8c1f6..5e8b934 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -45,9 +45,7 @@ export class QrTokenService { this.configService.get('QR_JWT_SECRET') || this.configService.get('JWT_SECRET', 'tu_clave_secreta'); - const fecha_fin = await this.cuestionarioRepository.findOne({ - where: { id_cuestionario }, - }); + const fecha_fin2 = await this.eventoREpo.findOne({ where: { id_evento }, @@ -84,9 +82,21 @@ export class QrTokenService { this.configService.get('JWT_SECRET', 'tu_clave_secreta'); console.log(secret) - + const decoded = this.jwtService.verify(token, { secret }); + const fecha_fin2 = await this.eventoREpo.findOne({ + where: { id_evento:decoded.id_evento }, + + }); + if (!fecha_fin2) { + throw new UnauthorizedException('Evento no encontrado'); + } + const now = new Date(); + if (fecha_fin2.fecha_fin < now) { + throw new Error('El evento ha finalizado'); + } + // Verificar que sea un token de tipo QR de asistencia if (decoded.type !== 'qr_asistencia') { return null; From 0745ddc8b4030eac256e99021fa100ea4f51882f Mon Sep 17 00:00:00 2001 From: evenegas Date: Wed, 1 Oct 2025 12:11:44 -0600 Subject: [PATCH 35/62] qr --- .../cuestionario_respondido.service.ts | 6 ++++-- src/qr/qr-token.service.ts | 16 ++++++---------- src/qr/qr.controller.ts | 5 ++++- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index 0f8d78a..47c120a 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -112,10 +112,11 @@ export class CuestionarioRespondidoService { if (cuestionario && cuestionario.evento) { try { // Generar token para el QR - qrToken = await this.qrTokenService.generateQrToken( + qrToken = this.qrTokenService.generateQrToken( participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, + cuestionario.fecha_fin ); // Generar código QR con el token @@ -336,10 +337,11 @@ export class CuestionarioRespondidoService { } // Generar token para el QR - qrToken = await this.qrTokenService.generateQrToken( + qrToken = this.qrTokenService.generateQrToken( participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, + cuestionario.fecha_fin ); qrBuffer = await QRCode.toBuffer(qrToken); diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index 5e8b934..ae13a9d 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -27,11 +27,12 @@ export class QrTokenService { * @param id_cuestionario ID del cuestionario * @returns Token JWT codificado */ - async generateQrToken( + generateQrToken( id_participante: number, id_evento: number, id_cuestionario: number, - ): Promise { + fecha_fin: Date, + ): string { const payload = { id_participante, id_evento, @@ -46,16 +47,11 @@ export class QrTokenService { this.configService.get('JWT_SECRET', 'tu_clave_secreta'); - const fecha_fin2 = await this.eventoREpo.findOne({ - where: { id_evento }, + - }); - - if (!fecha_fin2) { - throw new UnauthorizedException('Cuestionario no encontrado'); - } + // Convert fecha_fin to seconds from now for expiresIn - const expiresInSeconds = Math.floor((fecha_fin2?.fecha_fin.getTime() - Date.now()) / 1000); + const expiresInSeconds = Math.floor((fecha_fin.getTime() - Date.now()) / 1000); return this.jwtService.sign(payload, { secret, diff --git a/src/qr/qr.controller.ts b/src/qr/qr.controller.ts index b435d5f..f64f9c8 100644 --- a/src/qr/qr.controller.ts +++ b/src/qr/qr.controller.ts @@ -69,20 +69,23 @@ export class QrController { }; } - @Get('generate-token/:idParticipante/:idEvento/:idCuestionario') + @Get('generate-token/:idParticipante/:idEvento/:idCuestionario/:fechaFin') @ApiOperation({ summary: 'Genera un token JWT para QR de asistencia' }) @ApiParam({ name: 'idParticipante', description: 'ID del participante' }) @ApiParam({ name: 'idEvento', description: 'ID del evento' }) @ApiParam({ name: 'idCuestionario', description: 'ID del cuestionario' }) + @ApiParam({ name: 'fechaFin', description: 'Fecha de fin de validez del token (ISO 8601)' }) async generateQrToken( @Param('idParticipante', ParseIntPipe) idParticipante: number, @Param('idEvento', ParseIntPipe) idEvento: number, @Param('idCuestionario', ParseIntPipe) idCuestionario: number, + @Param('fechaFin') fechaFin: Date, ) { const token = await this.qrTokenService.generateQrToken( idParticipante, idEvento, idCuestionario, + fechaFin ); return { From 7b8c19fbd135ffa97f0c5958222d7ecf1766d487 Mon Sep 17 00:00:00 2001 From: evenegas Date: Wed, 1 Oct 2025 12:23:14 -0600 Subject: [PATCH 36/62] qr --- .../cuestionario_respondido.service.ts | 2 -- src/qr/qr-token.service.ts | 5 ++--- src/qr/qr.controller.ts | 8 +++----- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index 47c120a..9f3f426 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -116,7 +116,6 @@ export class CuestionarioRespondidoService { participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, - cuestionario.fecha_fin ); // Generar código QR con el token @@ -341,7 +340,6 @@ export class CuestionarioRespondidoService { participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, - cuestionario.fecha_fin ); qrBuffer = await QRCode.toBuffer(qrToken); diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index ae13a9d..529e835 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -31,7 +31,6 @@ export class QrTokenService { id_participante: number, id_evento: number, id_cuestionario: number, - fecha_fin: Date, ): string { const payload = { id_participante, @@ -51,11 +50,11 @@ export class QrTokenService { // Convert fecha_fin to seconds from now for expiresIn - const expiresInSeconds = Math.floor((fecha_fin.getTime() - Date.now()) / 1000); + return this.jwtService.sign(payload, { secret, - expiresIn: expiresInSeconds > 0 ? expiresInSeconds : 0, // El QR puede ser válido por 30 días + expiresIn: "30d", // El QR puede ser válido por 30 días }); } diff --git a/src/qr/qr.controller.ts b/src/qr/qr.controller.ts index f64f9c8..a926727 100644 --- a/src/qr/qr.controller.ts +++ b/src/qr/qr.controller.ts @@ -69,23 +69,21 @@ export class QrController { }; } - @Get('generate-token/:idParticipante/:idEvento/:idCuestionario/:fechaFin') + @Get('generate-token/:idParticipante/:idEvento/:idCuestionario') @ApiOperation({ summary: 'Genera un token JWT para QR de asistencia' }) @ApiParam({ name: 'idParticipante', description: 'ID del participante' }) @ApiParam({ name: 'idEvento', description: 'ID del evento' }) @ApiParam({ name: 'idCuestionario', description: 'ID del cuestionario' }) - @ApiParam({ name: 'fechaFin', description: 'Fecha de fin de validez del token (ISO 8601)' }) + async generateQrToken( @Param('idParticipante', ParseIntPipe) idParticipante: number, @Param('idEvento', ParseIntPipe) idEvento: number, @Param('idCuestionario', ParseIntPipe) idCuestionario: number, - @Param('fechaFin') fechaFin: Date, ) { - const token = await this.qrTokenService.generateQrToken( + const token = this.qrTokenService.generateQrToken( idParticipante, idEvento, idCuestionario, - fechaFin ); return { From be72ee5fa5f0d2b7105c24028a469a2223ccba76 Mon Sep 17 00:00:00 2001 From: evenegas Date: Wed, 1 Oct 2025 12:43:37 -0600 Subject: [PATCH 37/62] qr --- src/cuestionario/cuestionario.controller.ts | 6 +++--- src/cuestionario/cuestionario.service.ts | 5 +++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/cuestionario/cuestionario.controller.ts b/src/cuestionario/cuestionario.controller.ts index e8c3eb5..64f43c6 100644 --- a/src/cuestionario/cuestionario.controller.ts +++ b/src/cuestionario/cuestionario.controller.ts @@ -75,12 +75,12 @@ async cargaMasiva(@UploadedFile() file: Express.Multer.File) { throw new BadRequestException('No se ha subido ningún archivo'); } - const cuestionarios = await this.cargaMasiva(file); + const cuestionarios = await this.cuestionarioService.cargarEventosDesdeExcel(file.path); return { message: 'Carga masiva exitosa', - total: cuestionarios.length, - data: cuestionarios, + total: cuestionarios.data.length, + data: cuestionarios.data, }; } diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index fe01f30..2c71201 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -96,6 +96,7 @@ export class CuestionarioService { 2: 'registro-comunidad-trabajadores', 3: 'registro-general', }; + let data const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(filePath); @@ -193,11 +194,11 @@ export class CuestionarioService { }; // Llamada a tu servicio para guardar en DB - await this.createCuestionarioEvento(createDto); + data.push(await this.createCuestionarioEvento(createDto)); } } - return { message: 'Carga masiva completada' }; + return { message: 'Carga masiva completada', data }; } async create(createCuestionarioDto: CreateCuestionarioDto) { From 6eb53a327a504e41cf02eea1754d5f10f0a19070 Mon Sep 17 00:00:00 2001 From: santiago Date: Wed, 1 Oct 2025 12:46:12 -0600 Subject: [PATCH 38/62] Modificciones sin subir --- src/cuestionario/cuestionario.controller.ts | 22 ++++++++++----------- src/cuestionario/cuestionario.service.ts | 2 +- src/qr/qr-token.service.ts | 2 ++ 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/cuestionario/cuestionario.controller.ts b/src/cuestionario/cuestionario.controller.ts index ac2d9f0..5f472a1 100644 --- a/src/cuestionario/cuestionario.controller.ts +++ b/src/cuestionario/cuestionario.controller.ts @@ -71,19 +71,19 @@ export class CuestionarioController { } @Post('carga-masiva') -@UseInterceptors(FileInterceptor('file')) -async cargaMasiva(@UploadedFile() file: Express.Multer.File) { - if (!file) { - throw new BadRequestException('No se ha subido ningún archivo'); - } + @UseInterceptors(FileInterceptor('file')) + async cargaMasiva(@UploadedFile() file: Express.Multer.File) { + if (!file) { + throw new BadRequestException('No se ha subido ningún archivo'); + } - const cuestionarios = await this.cargaMasiva(file); + const cuestionarios = await this.cargaMasiva(file); - return { - message: 'Carga masiva exitosa', - total: cuestionarios.length, - data: cuestionarios, - }; + return { + message: 'Carga masiva exitosa', + total: cuestionarios.length, + data: cuestionarios, + }; } diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index fe01f30..7183ea2 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -132,7 +132,7 @@ export class CuestionarioService { const plantilla = qRow.getCell(9).value ? Number(row.getCell(8).value) : null; - if (plantilla && PLANTILLA_MAP[plantilla]) { + if (plantilla && PLANTILLA_MAP[plantilla]) { const fecha_inicio = new Date(qRow.getCell(4).value as string); const fecha_fin = new Date(qRow.getCell(5).value as string); const id_tipo_evento= Number(qRow.getCell(8).value); diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index 189b182..435cc6b 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -50,6 +50,7 @@ export class QrTokenService { if (!fecha_fin) { throw new UnauthorizedException('Cuestionario no encontrado'); } + // Convert fecha_fin to seconds from now for expiresIn const expiresInSeconds = Math.floor((fecha_fin?.fecha_fin.getTime() - Date.now()) / 1000); @@ -64,6 +65,7 @@ export class QrTokenService { * @param token Token JWT a validar * @returns Datos decodificados del token o null si es inválido */ + async validateQrToken(token: string): Promise<{ id_participante : number; From 613a44c3554bf3504122dadbb83d9a49331844b6 Mon Sep 17 00:00:00 2001 From: santiago Date: Wed, 1 Oct 2025 15:37:30 -0600 Subject: [PATCH 39/62] Se modifico el qr y la carga masiva --- src/cuestionario/cuestionario.controller.ts | 2 -- src/cuestionario/cuestionario.service.ts | 11 +++++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/cuestionario/cuestionario.controller.ts b/src/cuestionario/cuestionario.controller.ts index 437705f..af4099e 100644 --- a/src/cuestionario/cuestionario.controller.ts +++ b/src/cuestionario/cuestionario.controller.ts @@ -45,8 +45,6 @@ export class CuestionarioController { destination: './uploads/banners', filename: (req, file, cb) => { cb(null, file.originalname); - - }, }), fileFilter: (req, file, cb) => { diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index bff0b4a..b4f3cec 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -56,6 +56,10 @@ export class CuestionarioService { private mapTipoPregunta(tipo: string): TipoPreguntaEnum { + if (!tipo) { + console.log(tipo); + throw new Error('El campo "tipo de pregunta" está vacío en el Excel'); + } switch (tipo.toLowerCase()) { case 'cerrada': return TipoPreguntaEnum.Cerrada; case 'multiple': return TipoPreguntaEnum.Multiple; @@ -132,7 +136,6 @@ export class CuestionarioService { const plantilla = qRow.getCell(9).value ? Number(row.getCell(8).value) : null; - if (plantilla && PLANTILLA_MAP[plantilla]) { const fecha_inicio = new Date(qRow.getCell(4).value as string); const fecha_fin = new Date(qRow.getCell(5).value as string); @@ -145,12 +148,9 @@ export class CuestionarioService { fecha_inicio, fecha_fin, id_tipo_evento, - - }; } - // Filtrar secciones de este cuestionario const secciones = seccionesSheet.getRows(2, seccionesSheet.rowCount - 1) ?.filter(sRow => sRow.getCell(2).value === qRow.getCell(1).value) @@ -163,6 +163,9 @@ export class CuestionarioService { ?.filter(oRow => oRow.getCell(2).value === pRow.getCell(1).value) .map(oRow => ({ valor: oRow.getCell(3).value as string })) || []; + console.log("Este es el titulo: ", pRow.getCell(3).value as string); + console.log("Este es el tipo de la pregunta: ",pRow.getCell(4).value as string); + return { titulo: pRow.getCell(3).value as string, tipo: this.mapTipoPregunta(pRow.getCell(4).value as string), From 663be0c7c611fbd1b3242902893cda1c85dac68f Mon Sep 17 00:00:00 2001 From: evenegas Date: Wed, 1 Oct 2025 17:52:36 -0600 Subject: [PATCH 40/62] =?UTF-8?q?Se=20agreg=C3=B3=20plantillas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cuestionario/cuestionario.service.ts | 17 ++++++-- src/cuestionario/plantillas.ts | 55 +++++++++++++----------- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index b4f3cec..ae6a217 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -134,7 +134,7 @@ export class CuestionarioService { for (const qRow of cuestionarios) { - const plantilla = qRow.getCell(9).value ? Number(row.getCell(8).value) : null; + const plantilla = qRow.getCell(9).value ? Number(row.getCell(9).value) : null; if (plantilla && PLANTILLA_MAP[plantilla]) { const fecha_inicio = new Date(qRow.getCell(4).value as string); @@ -143,13 +143,22 @@ export class CuestionarioService { const plantillaId = PLANTILLA_MAP[plantilla]; const base = PLANTILLAS.find(p => p.id === plantillaId); if (!base) throw new Error(`Plantilla ${plantillaId} no encontrada`); - return { + + const createDto: CreateEventoWithCuestionarioDto = { + evento: eventoDto, + cuestionario: { ...base.datos, fecha_inicio, fecha_fin, id_tipo_evento, - }; - } + }, + }; + + + data.push(await this.createCuestionarioEvento(createDto)); + + continue; // Saltar al siguiente cuestionario + } // Filtrar secciones de este cuestionario const secciones = seccionesSheet.getRows(2, seccionesSheet.rowCount - 1) diff --git a/src/cuestionario/plantillas.ts b/src/cuestionario/plantillas.ts index 6a359d9..98da0a7 100644 --- a/src/cuestionario/plantillas.ts +++ b/src/cuestionario/plantillas.ts @@ -1,3 +1,10 @@ +import { TiposValidacion } from 'src/pregunta/entities/pregunta.entity'; +import { + TipoPreguntaEnum, +} from '../tipo_pregunta/entities/tipo_pregunta.entity'; + + + export const PLANTILLAS = [ { imagen: '/plantilla_trabajadores.png', @@ -16,27 +23,27 @@ export const PLANTILLAS = [ preguntas: [ { titulo: '¿Eres parte de la comunidad de la FES Acatlán?', - tipo: 'Cerrada', + tipo: TipoPreguntaEnum.Cerrada, opciones: [{ valor: 'Si' }, { valor: 'No' }], obligatoria: true, - validacion: 'comunidad_trabajador', + validacion: TiposValidacion.COMUNIDAD_TRABAJADOR, }, - { titulo: 'RFC', tipo: 'Abierta (Respuesta corta)', obligatoria: false, validacion: 'rfc' }, - { titulo: 'Correo electrónico', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'correo' }, - { titulo: 'Nombre(s)', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'nombre' }, - { titulo: 'Apellidos', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'apellidos' }, + { titulo: 'RFC', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: false, validacion: TiposValidacion.RFC }, + { titulo: 'Correo electrónico', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CORREO }, + { titulo: 'Nombre(s)', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.NOMBRE }, + { titulo: 'Apellidos', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.APELLIDOS }, { titulo: 'Género', - tipo: 'Cerrada', + tipo: TipoPreguntaEnum.Cerrada, obligatoria: true, - validacion: 'genero', + validacion: TiposValidacion.GENERO, opciones: [ { valor: 'Masculino' }, { valor: 'Femenino' }, { valor: 'Prefiero no decirlo' }, ], }, - { titulo: 'Carrera', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'carrera' }, + { titulo: 'Carrera', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CARRERA }, ], }, ], @@ -59,28 +66,28 @@ export const PLANTILLAS = [ preguntas: [ { titulo: '¿Eres parte de la comunidad de la FES Acatlán?', - tipo: 'Cerrada', + tipo: TipoPreguntaEnum.Cerrada, opciones: [{ valor: 'Si' }, { valor: 'No' }], obligatoria: true, - validacion: 'comunidad_alumno', + validacion: TiposValidacion.COMUNIDAD_ALUMNO, }, - { titulo: 'Numero de cuenta', tipo: 'Abierta (Respuesta corta)', obligatoria: false, validacion: 'cuenta_alumno' }, - { titulo: 'Correo electrónico', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'correo' }, - { titulo: 'Nombre(s)', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'nombre' }, - { titulo: 'Apellidos', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'apellidos' }, + { titulo: 'Numero de cuenta', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: false, validacion: TiposValidacion.CUENTA_ALUMNO }, + { titulo: 'Correo electrónico', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CORREO }, + { titulo: 'Nombre(s)', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.NOMBRE }, + { titulo: 'Apellidos', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.APELLIDOS }, { titulo: 'Género', - tipo: 'Cerrada', + tipo: TipoPreguntaEnum.Cerrada, obligatoria: true, - validacion: 'genero', + validacion: TiposValidacion.GENERO, opciones: [ { valor: 'Masculino' }, { valor: 'Femenino' }, { valor: 'Prefiero no decirlo' }, ], }, - { titulo: 'Institución de procedencia', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'institucion' }, - { titulo: 'Carrera', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'carrera' }, + { titulo: 'Institución de procedencia', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.INSTITUCION }, + { titulo: 'Carrera', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CARRERA }, ], }, ], @@ -101,13 +108,13 @@ export const PLANTILLAS = [ descripcion: 'Proporciona tus datos personales para poder contactarte y enviarte información relevante sobre la Feria de la Sexualidad.', preguntas: [ - { titulo: 'Número de cuenta / trabajador', tipo: 'Abierta (Respuesta corta)', obligatoria: false }, - { titulo: 'Correo electrónico', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'correo' }, - { titulo: 'Nombre(s)', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'nombre' }, - { titulo: 'Apellidos', tipo: 'Abierta (Respuesta corta)', obligatoria: true, validacion: 'apellidos' }, + { titulo: 'Número de cuenta / trabajador', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: false }, + { titulo: 'Correo electrónico', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CORREO }, + { titulo: 'Nombre(s)', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.NOMBRE }, + { titulo: 'Apellidos', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.APELLIDOS }, { titulo: 'Tipo de Asistente', - tipo: 'Cerrada', + tipo: TipoPreguntaEnum.Cerrada, obligatoria: true, opciones: [ { valor: 'Alumno' }, From 2a7a10ad9b2f373e37994d208d197835fd13495b Mon Sep 17 00:00:00 2001 From: evenegas Date: Thu, 2 Oct 2025 10:31:42 -0600 Subject: [PATCH 41/62] plantilla --- src/cuestionario/cuestionario.service.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index ae6a217..eab913d 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -134,7 +134,7 @@ export class CuestionarioService { for (const qRow of cuestionarios) { - const plantilla = qRow.getCell(9).value ? Number(row.getCell(9).value) : null; + const plantilla = qRow.getCell(9).value ? Number(qRow.getCell(9).value) : null; if (plantilla && PLANTILLA_MAP[plantilla]) { const fecha_inicio = new Date(qRow.getCell(4).value as string); @@ -143,7 +143,9 @@ export class CuestionarioService { const plantillaId = PLANTILLA_MAP[plantilla]; const base = PLANTILLAS.find(p => p.id === plantillaId); if (!base) throw new Error(`Plantilla ${plantillaId} no encontrada`); - + console.log("ID de la plantilla: ", plantillaId); + console.log("Base de la plantilla: ", base); + const createDto: CreateEventoWithCuestionarioDto = { evento: eventoDto, cuestionario: { From 55a96d16f63f37420061ec746ca5ef132323389f Mon Sep 17 00:00:00 2001 From: evenegas Date: Thu, 2 Oct 2025 10:48:26 -0600 Subject: [PATCH 42/62] plantilla --- src/cuestionario/cuestionario.service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index eab913d..88d6e15 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -133,9 +133,14 @@ export class CuestionarioService { ?.filter(qRow => qRow.getCell(2).value === row.getCell(1).value) || []; for (const qRow of cuestionarios) { + + const plantilla = qRow.getCell(9).value ? Number(qRow.getCell(9).value) : null; - + console.log("Valor de la plantilla: ", plantilla); + if (plantilla !== null && plantilla !== undefined) { + console.log("Tipo de la plantilla: ", PLANTILLA_MAP[plantilla]); + } if (plantilla && PLANTILLA_MAP[plantilla]) { const fecha_inicio = new Date(qRow.getCell(4).value as string); const fecha_fin = new Date(qRow.getCell(5).value as string); From 3cccee58f60b8b79b0ecfb0de409ad0f52876933 Mon Sep 17 00:00:00 2001 From: evenegas Date: Thu, 2 Oct 2025 11:02:44 -0600 Subject: [PATCH 43/62] plantilla --- src/cuestionario/cuestionario.service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index 88d6e15..cb143c9 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -143,7 +143,9 @@ export class CuestionarioService { } if (plantilla && PLANTILLA_MAP[plantilla]) { const fecha_inicio = new Date(qRow.getCell(4).value as string); + console.log("Fecha de inicio: ", fecha_inicio); const fecha_fin = new Date(qRow.getCell(5).value as string); + console.log("Fecha de inicio: ", fecha_fin); const id_tipo_evento= Number(qRow.getCell(8).value); const plantillaId = PLANTILLA_MAP[plantilla]; const base = PLANTILLAS.find(p => p.id === plantillaId); From 2ea0119b8783e5606657963cbb8647bcfee14940 Mon Sep 17 00:00:00 2001 From: evenegas Date: Thu, 2 Oct 2025 11:08:51 -0600 Subject: [PATCH 44/62] plantilla --- src/cuestionario/cuestionario.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index cb143c9..53d88f7 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -142,10 +142,12 @@ export class CuestionarioService { console.log("Tipo de la plantilla: ", PLANTILLA_MAP[plantilla]); } if (plantilla && PLANTILLA_MAP[plantilla]) { + console.log("fecha fin desde ecxel: ",qRow.getCell(4).value as string); const fecha_inicio = new Date(qRow.getCell(4).value as string); + console.log("Fecha de inicio: ", fecha_inicio); const fecha_fin = new Date(qRow.getCell(5).value as string); - console.log("Fecha de inicio: ", fecha_fin); + console.log("Fecha de fin: ", fecha_fin); const id_tipo_evento= Number(qRow.getCell(8).value); const plantillaId = PLANTILLA_MAP[plantilla]; const base = PLANTILLAS.find(p => p.id === plantillaId); From d070a4bc8fb1b6ce919d3278f1f6a32565bdb035 Mon Sep 17 00:00:00 2001 From: evenegas Date: Thu, 2 Oct 2025 11:10:35 -0600 Subject: [PATCH 45/62] plantilla --- src/cuestionario/cuestionario.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index 53d88f7..4bd93e5 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -142,11 +142,11 @@ export class CuestionarioService { console.log("Tipo de la plantilla: ", PLANTILLA_MAP[plantilla]); } if (plantilla && PLANTILLA_MAP[plantilla]) { - console.log("fecha fin desde ecxel: ",qRow.getCell(4).value as string); - const fecha_inicio = new Date(qRow.getCell(4).value as string); + console.log("fecha fin desde ecxel: ",qRow.getCell(5).value as string); + const fecha_inicio = new Date(qRow.getCell(5).value as string); console.log("Fecha de inicio: ", fecha_inicio); - const fecha_fin = new Date(qRow.getCell(5).value as string); + const fecha_fin = new Date(qRow.getCell(6).value as string); console.log("Fecha de fin: ", fecha_fin); const id_tipo_evento= Number(qRow.getCell(8).value); const plantillaId = PLANTILLA_MAP[plantilla]; From 00e758f0ef328678e4aee1ae15ab8c538b370f43 Mon Sep 17 00:00:00 2001 From: santiago Date: Thu, 2 Oct 2025 16:03:52 -0600 Subject: [PATCH 46/62] Modificaciones --- src/cuestionario/cuestionario.service.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index 4bd93e5..ee27255 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -383,17 +383,19 @@ export class CuestionarioService { await queryRunner.startTransaction(); try { + /* // Buscar si ya existe un evento con el nombre proporcionado let eventoExistente = await this.eventoRepository.findOne({ where: { nombre_evento: evento.nombre_evento }, }); + **/ // Si el evento no existe, crearlo - if (!eventoExistente) { - eventoExistente = queryRunner.manager.create(Evento, evento); + //if (!eventoExistente) { + let eventoExistente = queryRunner.manager.create(Evento, evento); await queryRunner.manager.save(eventoExistente); console.log(`Creando nuevo evento: ${evento.nombre_evento}`); - } + //} // Crear el cuestionario asociado al evento const cuestionarioEntity = queryRunner.manager.create(Cuestionario, { From e4c2a16c8e01776ab4cf520e5783e33fdfefc8a4 Mon Sep 17 00:00:00 2001 From: evenegas Date: Fri, 3 Oct 2025 12:01:18 -0600 Subject: [PATCH 47/62] =?UTF-8?q?se=20agreg=C3=B3=20cupo=20m=C3=A1ximo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cuestionario/cuestionario.service.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index ee27255..295743d 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -136,7 +136,7 @@ export class CuestionarioService { - const plantilla = qRow.getCell(9).value ? Number(qRow.getCell(9).value) : null; + const plantilla = qRow.getCell(10).value ? Number(qRow.getCell(10).value) : null; console.log("Valor de la plantilla: ", plantilla); if (plantilla !== null && plantilla !== undefined) { console.log("Tipo de la plantilla: ", PLANTILLA_MAP[plantilla]); @@ -144,6 +144,7 @@ export class CuestionarioService { if (plantilla && PLANTILLA_MAP[plantilla]) { console.log("fecha fin desde ecxel: ",qRow.getCell(5).value as string); const fecha_inicio = new Date(qRow.getCell(5).value as string); + console.log("Fecha de inicio: ", fecha_inicio); const fecha_fin = new Date(qRow.getCell(6).value as string); @@ -154,7 +155,10 @@ export class CuestionarioService { if (!base) throw new Error(`Plantilla ${plantillaId} no encontrada`); console.log("ID de la plantilla: ", plantillaId); console.log("Base de la plantilla: ", base); - + const nombre_form= qRow.getCell(3).value as string; + const descripcion= qRow.getCell(4).value as string; + const cupo_maximo= Number(qRow.getCell(9).value) || undefined; + const createDto: CreateEventoWithCuestionarioDto = { evento: eventoDto, cuestionario: { @@ -162,6 +166,10 @@ export class CuestionarioService { fecha_inicio, fecha_fin, id_tipo_evento, + nombre_form, + descripcion, + cupo_maximo, // Aquí puedes agregar más campos si es necesario + }, }; @@ -212,6 +220,7 @@ export class CuestionarioService { fecha_fin: new Date(qRow.getCell(6).value as string), id_tipo_cuestionario: Number(qRow.getCell(7).value), id_tipo_evento: Number(qRow.getCell(8).value), // obligatorio + cupo_maximo: Number(qRow.getCell(9).value) || undefined, secciones, }, }; From 0837bdd17bac3939f971594beaedd1c6639411f9 Mon Sep 17 00:00:00 2001 From: evenegas Date: Fri, 3 Oct 2025 12:57:56 -0600 Subject: [PATCH 48/62] =?UTF-8?q?se=20agreg=C3=B3=20log=20de=20fecha=20fin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/qr/qr-token.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index 1d7a07c..105d6a4 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -47,7 +47,7 @@ export class QrTokenService { this.configService.get('JWT_SECRET', 'tu_clave_secreta'); - + console.log("fecha de caducidad:",fecha_fin); // Convert fecha_fin to seconds from now for expiresIn const expiresInSeconds = Math.floor((fecha_fin.getTime() - Date.now()) / 1000); From e7b3540170211534f0a13784a614d6ff6802f693 Mon Sep 17 00:00:00 2001 From: evenegas Date: Fri, 3 Oct 2025 14:08:17 -0600 Subject: [PATCH 49/62] =?UTF-8?q?se=20agreg=C3=B3=20expiraci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/qr/qr-token.service.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index 105d6a4..d045236 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -50,7 +50,7 @@ export class QrTokenService { console.log("fecha de caducidad:",fecha_fin); // Convert fecha_fin to seconds from now for expiresIn const expiresInSeconds = Math.floor((fecha_fin.getTime() - Date.now()) / 1000); - + console.log("expiresInSeconds:",expiresInSeconds); return this.jwtService.sign(payload, { secret, expiresIn: expiresInSeconds > 0 ? expiresInSeconds : 0, // El QR puede ser válido por 30 días @@ -69,6 +69,7 @@ export class QrTokenService { id_cuestionario: number; type: string; iat: number; + exp?: number; } | null >{ try { const secret = @@ -83,17 +84,20 @@ export class QrTokenService { where: { id_evento:decoded.id_evento }, }); - + if (!fecha_fin2) { throw new UnauthorizedException('Evento no encontrado'); } + + fecha_fin2 + const fechaFinEvento = new Date(fecha_fin2.fecha_fin); // UTC const now = new Date(); - console.log(fechaFinEvento.getTime()) - console.log(now.getTime) + console.log(fechaFinEvento) + console.log(now.getTime()) if (fechaFinEvento.getTime() < now.getTime()) { throw new Error('El evento ha finalizado'); } @@ -109,6 +113,7 @@ export class QrTokenService { id_cuestionario: decoded.id_cuestionario, type: decoded.type, iat: decoded.iat, + exp: decoded.exp, }; } catch (error) { console.error('Error validando token QR:', error.message); From 8196ea8a3118fafd541c63f1eed355d68c1495e4 Mon Sep 17 00:00:00 2001 From: evenegas Date: Fri, 3 Oct 2025 14:27:18 -0600 Subject: [PATCH 50/62] =?UTF-8?q?se=20agreg=C3=B3=20expiraci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/participante_evento/participante_evento.service.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/participante_evento/participante_evento.service.ts b/src/participante_evento/participante_evento.service.ts index f93433d..51837e9 100644 --- a/src/participante_evento/participante_evento.service.ts +++ b/src/participante_evento/participante_evento.service.ts @@ -268,10 +268,7 @@ export class ParticipanteEventoService { const tokenData = this.qrTokenService.validateQrToken(qrToken); if (!tokenData) { - throw new HttpException( - 'Token QR inválido o expirado', - HttpStatus.BAD_REQUEST, - ); + return null; } return tokenData From 3db1076ee64a8eb43d9328ea170a31b2f2ed52bf Mon Sep 17 00:00:00 2001 From: santiago Date: Fri, 3 Oct 2025 14:29:14 -0600 Subject: [PATCH 51/62] Modificaciones --- src/cuestionario/cuestionario.service.ts | 2 +- src/qr/qr-token.service.ts | 60 +++++++++++++----------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index 295743d..74f1fdf 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -148,7 +148,7 @@ export class CuestionarioService { console.log("Fecha de inicio: ", fecha_inicio); const fecha_fin = new Date(qRow.getCell(6).value as string); - console.log("Fecha de fin: ", fecha_fin); + console.log("Fecha de fin: ", fecha_fin); const id_tipo_evento= Number(qRow.getCell(8).value); const plantillaId = PLANTILLA_MAP[plantilla]; const base = PLANTILLAS.find(p => p.id === plantillaId); diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index 105d6a4..45c9360 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -51,9 +51,11 @@ export class QrTokenService { // Convert fecha_fin to seconds from now for expiresIn const expiresInSeconds = Math.floor((fecha_fin.getTime() - Date.now()) / 1000); + console.log("Caducidad token: ", Math.floor((fecha_fin.getTime() - Date.now()) / 1000)); + return this.jwtService.sign(payload, { secret, - expiresIn: expiresInSeconds > 0 ? expiresInSeconds : 0, // El QR puede ser válido por 30 días + expiresIn: expiresInSeconds > 0 ? expiresInSeconds : 3600, // El QR puede ser válido por 30 días }); } @@ -70,6 +72,7 @@ export class QrTokenService { type: string; iat: number; } | null >{ + try { const secret = this.configService.get('QR_JWT_SECRET') || @@ -79,37 +82,40 @@ export class QrTokenService { const decoded = this.jwtService.verify(token, { secret }); + //console.log("fecha fin:", fecha_fin2.fecha_fin); + console.log("este es el token", decoded); + const fecha_fin2 = await this.eventoREpo.findOne({ - where: { id_evento:decoded.id_evento }, + where: { id_evento:decoded.id_evento }, - }); + }); - - - - if (!fecha_fin2) { - throw new UnauthorizedException('Evento no encontrado'); - } - const fechaFinEvento = new Date(fecha_fin2.fecha_fin); // UTC - const now = new Date(); - console.log(fechaFinEvento.getTime()) - console.log(now.getTime) - if (fechaFinEvento.getTime() < now.getTime()) { - throw new Error('El evento ha finalizado'); - } - - // Verificar que sea un token de tipo QR de asistencia - if (decoded.type !== 'qr_asistencia') { - return null; + if (!fecha_fin2) { + throw new UnauthorizedException('Evento no encontrado'); + } + const fechaFinEvento = new Date(fecha_fin2.fecha_fin); // UTC + const now = new Date(); + console.log("Fecha fin cruda (de DB):", fecha_fin2.fecha_fin); + console.log("Fecha fin convertida:", fechaFinEvento.toISOString()); + console.log("Fecha fin timestamp:", fechaFinEvento.getTime()); + console.log("Fecha actual:", now.toISOString()); + console.log("Fecha actual timestamp:", now.getTime()); + if (fechaFinEvento.getTime() < now.getTime()) { + throw new Error('El evento ha finalizado'); } - return { - id_participante: decoded.id_participante, - id_evento: decoded.id_evento, - id_cuestionario: decoded.id_cuestionario, - type: decoded.type, - iat: decoded.iat, - }; + // Verificar que sea un token de tipo QR de asistencia + if (decoded.type !== 'qr_asistencia') { + return null; + } + + return { + id_participante: decoded.id_participante, + id_evento: decoded.id_evento, + id_cuestionario: decoded.id_cuestionario, + type: decoded.type, + iat: decoded.iat, + }; } catch (error) { console.error('Error validando token QR:', error.message); return null; From e4ce66684fed73f9b2690b0be2cbb61ededee9fb Mon Sep 17 00:00:00 2001 From: evenegas Date: Mon, 6 Oct 2025 12:18:52 -0600 Subject: [PATCH 52/62] =?UTF-8?q?Se=20agreg=C3=B3=20una=20nueva=20plantill?= =?UTF-8?q?a?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cuestionario/cuestionario.service.ts | 1 + src/cuestionario/plantillas.ts | 35 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/cuestionario/cuestionario.service.ts b/src/cuestionario/cuestionario.service.ts index 74f1fdf..b5f9ef7 100644 --- a/src/cuestionario/cuestionario.service.ts +++ b/src/cuestionario/cuestionario.service.ts @@ -99,6 +99,7 @@ export class CuestionarioService { 1: 'registro-comunidad-alumnos', 2: 'registro-comunidad-trabajadores', 3: 'registro-general', + 4: 'registro-general-externos', }; let data: any[] = []; diff --git a/src/cuestionario/plantillas.ts b/src/cuestionario/plantillas.ts index 98da0a7..34c4e7c 100644 --- a/src/cuestionario/plantillas.ts +++ b/src/cuestionario/plantillas.ts @@ -127,4 +127,39 @@ export const PLANTILLAS = [ ], }, }, + { + imagen: '/plantilla_general.png', + nombre: 'Registro General Externos', + id: 'registro-general-externos', + datos: { + nombre_form: 'Registro Para Asistencia General', + descripcion: + 'Con este formulario estaras reservando tu lugar en el evento. Por favor, completa todos los campos obligatorios.', + 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: 'Número de cuenta / trabajador', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: false }, + { titulo: 'Correo electrónico', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.CORREO }, + { titulo: 'Nombre(s)', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.NOMBRE }, + { titulo: 'Apellidos', tipo: TipoPreguntaEnum.AbiertaRespuestaCorta, obligatoria: true, validacion: TiposValidacion.APELLIDOS }, + { + titulo: 'Tipo de Asistente', + tipo: TipoPreguntaEnum.Cerrada, + obligatoria: true, + opciones: [ + { valor: 'Alumno' }, + { valor: 'Profesor' }, + { valor: 'Trabajador' }, + { valor: 'Externo'}, + ], + }, + ], + }, + ], + }, + }, ]; From 41d36f540abe6f73e70b072036cf304d72af8ef4 Mon Sep 17 00:00:00 2001 From: evenegas Date: Wed, 1 Oct 2025 10:14:27 -0600 Subject: [PATCH 53/62] Se creo un evento por xlsx --- .../cuestionario_respondido.service.ts | 4 ++-- src/qr/qr-token.service.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index 47c120a..bc87423 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -112,7 +112,7 @@ export class CuestionarioRespondidoService { if (cuestionario && cuestionario.evento) { try { // Generar token para el QR - qrToken = this.qrTokenService.generateQrToken( + qrToken = await this.qrTokenService.generateQrToken( participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, @@ -337,7 +337,7 @@ export class CuestionarioRespondidoService { } // Generar token para el QR - qrToken = this.qrTokenService.generateQrToken( + qrToken = await this.qrTokenService.generateQrToken( participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index 0ab2268..7beb639 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -27,7 +27,7 @@ export class QrTokenService { * @param id_cuestionario ID del cuestionario * @returns Token JWT codificado */ - generateQrToken( + async generateQrToken( id_participante: number, id_evento: number, id_cuestionario: number, From 12080aa83ed858967938cd29cf6d7940f7fd9449 Mon Sep 17 00:00:00 2001 From: evenegas Date: Wed, 1 Oct 2025 12:11:44 -0600 Subject: [PATCH 54/62] qr --- .../cuestionario_respondido.service.ts | 4 ++-- src/qr/qr-token.service.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index bc87423..47c120a 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -112,7 +112,7 @@ export class CuestionarioRespondidoService { if (cuestionario && cuestionario.evento) { try { // Generar token para el QR - qrToken = await this.qrTokenService.generateQrToken( + qrToken = this.qrTokenService.generateQrToken( participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, @@ -337,7 +337,7 @@ export class CuestionarioRespondidoService { } // Generar token para el QR - qrToken = await this.qrTokenService.generateQrToken( + qrToken = this.qrTokenService.generateQrToken( participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index 7beb639..0ab2268 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -27,7 +27,7 @@ export class QrTokenService { * @param id_cuestionario ID del cuestionario * @returns Token JWT codificado */ - async generateQrToken( + generateQrToken( id_participante: number, id_evento: number, id_cuestionario: number, From e81abcb66567257e211598fb418dfb8dea253fb9 Mon Sep 17 00:00:00 2001 From: santiago Date: Fri, 3 Oct 2025 14:29:14 -0600 Subject: [PATCH 55/62] Modificaciones --- src/qr/qr-token.service.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index 0ab2268..e884134 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -110,13 +110,13 @@ export class QrTokenService { return null; } - return { - id_participante: decoded.id_participante, - id_evento: decoded.id_evento, - id_cuestionario: decoded.id_cuestionario, - type: decoded.type, - iat: decoded.iat, - }; + return { + id_participante: decoded.id_participante, + id_evento: decoded.id_evento, + id_cuestionario: decoded.id_cuestionario, + type: decoded.type, + iat: decoded.iat, + }; } catch (error) { console.error('Error validando token QR:', error.message); return null; From 53fe436e343f2958b7637b32ead8651e6b56cec6 Mon Sep 17 00:00:00 2001 From: evenegas Date: Fri, 3 Oct 2025 14:08:17 -0600 Subject: [PATCH 56/62] =?UTF-8?q?se=20agreg=C3=B3=20expiraci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/qr/qr-token.service.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index e884134..1d2fa6e 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -110,13 +110,14 @@ export class QrTokenService { return null; } - return { - id_participante: decoded.id_participante, - id_evento: decoded.id_evento, - id_cuestionario: decoded.id_cuestionario, - type: decoded.type, - iat: decoded.iat, - }; + return { + id_participante: decoded.id_participante, + id_evento: decoded.id_evento, + id_cuestionario: decoded.id_cuestionario, + type: decoded.type, + iat: decoded.iat, + exp: decoded.exp, + }; } catch (error) { console.error('Error validando token QR:', error.message); return null; From ec76bb48353c14a2140a818ae07b1d3ee56b6079 Mon Sep 17 00:00:00 2001 From: evenegas Date: Mon, 6 Oct 2025 13:31:44 -0600 Subject: [PATCH 57/62] merge --- .../cuestionario_respondido.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index 47c120a..bc87423 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -112,7 +112,7 @@ export class CuestionarioRespondidoService { if (cuestionario && cuestionario.evento) { try { // Generar token para el QR - qrToken = this.qrTokenService.generateQrToken( + qrToken = await this.qrTokenService.generateQrToken( participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, @@ -337,7 +337,7 @@ export class CuestionarioRespondidoService { } // Generar token para el QR - qrToken = this.qrTokenService.generateQrToken( + qrToken = await this.qrTokenService.generateQrToken( participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, From ba1f39502e0368aaaea45d40ab23f39b95bae880 Mon Sep 17 00:00:00 2001 From: evenegas Date: Wed, 1 Oct 2025 12:11:44 -0600 Subject: [PATCH 58/62] qr --- .../cuestionario_respondido.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cuestionario_respondido/cuestionario_respondido.service.ts b/src/cuestionario_respondido/cuestionario_respondido.service.ts index bc87423..47c120a 100644 --- a/src/cuestionario_respondido/cuestionario_respondido.service.ts +++ b/src/cuestionario_respondido/cuestionario_respondido.service.ts @@ -112,7 +112,7 @@ export class CuestionarioRespondidoService { if (cuestionario && cuestionario.evento) { try { // Generar token para el QR - qrToken = await this.qrTokenService.generateQrToken( + qrToken = this.qrTokenService.generateQrToken( participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, @@ -337,7 +337,7 @@ export class CuestionarioRespondidoService { } // Generar token para el QR - qrToken = await this.qrTokenService.generateQrToken( + qrToken = this.qrTokenService.generateQrToken( participante.id_participante, cuestionario.evento.id_evento, cuestionario.id_cuestionario, From 258a03e7b41013e96231a60541b4f858f44fa6e8 Mon Sep 17 00:00:00 2001 From: santiago Date: Fri, 3 Oct 2025 14:29:14 -0600 Subject: [PATCH 59/62] Modificaciones --- src/qr/qr-token.service.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index 1d2fa6e..e884134 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -110,14 +110,13 @@ export class QrTokenService { return null; } - return { - id_participante: decoded.id_participante, - id_evento: decoded.id_evento, - id_cuestionario: decoded.id_cuestionario, - type: decoded.type, - iat: decoded.iat, - exp: decoded.exp, - }; + return { + id_participante: decoded.id_participante, + id_evento: decoded.id_evento, + id_cuestionario: decoded.id_cuestionario, + type: decoded.type, + iat: decoded.iat, + }; } catch (error) { console.error('Error validando token QR:', error.message); return null; From 4615306784a34d48d0d413e0b3e044cc78c82d22 Mon Sep 17 00:00:00 2001 From: evenegas Date: Fri, 3 Oct 2025 14:08:17 -0600 Subject: [PATCH 60/62] =?UTF-8?q?se=20agreg=C3=B3=20expiraci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/qr/qr-token.service.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/qr/qr-token.service.ts b/src/qr/qr-token.service.ts index e884134..1d2fa6e 100644 --- a/src/qr/qr-token.service.ts +++ b/src/qr/qr-token.service.ts @@ -110,13 +110,14 @@ export class QrTokenService { return null; } - return { - id_participante: decoded.id_participante, - id_evento: decoded.id_evento, - id_cuestionario: decoded.id_cuestionario, - type: decoded.type, - iat: decoded.iat, - }; + return { + id_participante: decoded.id_participante, + id_evento: decoded.id_evento, + id_cuestionario: decoded.id_cuestionario, + type: decoded.type, + iat: decoded.iat, + exp: decoded.exp, + }; } catch (error) { console.error('Error validando token QR:', error.message); return null; From c259649a8da036ca326cfea94fbabda38701b25a Mon Sep 17 00:00:00 2001 From: Emilio Date: Tue, 21 Oct 2025 17:44:51 -0600 Subject: [PATCH 61/62] cambio en el correo --- src/emails/registro-evento.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/emails/registro-evento.ts b/src/emails/registro-evento.ts index bfbe999..ad17fce 100644 --- a/src/emails/registro-evento.ts +++ b/src/emails/registro-evento.ts @@ -34,7 +34,7 @@ export function generarHtmlCorreoAsistencia({

    - Favor de presentar este código QR (en tu celular o impreso) en las mesas de juegos el día del evento, para participar por tu premio. + Favor de presentar este código QR (en tu celular o impreso) en la mesa de atención de la Sala Emma Rizo 10 minutos antes de la hora de acceso reservada.

    From 29ee4e1999b6ede7ce459353affcd4ebcf0ada0b Mon Sep 17 00:00:00 2001 From: Emilio Date: Wed, 22 Oct 2025 11:36:49 -0600 Subject: [PATCH 62/62] filtado de eventos --- src/evento/evento.service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/evento/evento.service.ts b/src/evento/evento.service.ts index b7edb73..7078e08 100644 --- a/src/evento/evento.service.ts +++ b/src/evento/evento.service.ts @@ -127,7 +127,10 @@ export class EventoService { relations: ['cuestionarios', 'cuestionarios.cuestionariosRespondidos'], }); - const eventosConCupos = eventos.map((evento) => { + const eventosFiltrados = eventos.filter( + (evento) => !(evento.id_evento >= 10 && evento.id_evento <= 148), + ); + const eventosConCupos = eventosFiltrados.map((evento) => { return { ...evento, cuestionarios: evento.cuestionarios.map((cuest) => {